diff --git a/.codex-home-desktop-wait.png b/.codex-home-desktop-wait.png new file mode 100644 index 00000000..1cd523f7 Binary files /dev/null and b/.codex-home-desktop-wait.png differ diff --git a/.codex-home-desktop.png b/.codex-home-desktop.png new file mode 100644 index 00000000..68698ffa Binary files /dev/null and b/.codex-home-desktop.png differ diff --git a/.codex-home-mobile-wait.png b/.codex-home-mobile-wait.png new file mode 100644 index 00000000..d404bce4 Binary files /dev/null and b/.codex-home-mobile-wait.png differ diff --git a/.codex-home-mobile.png b/.codex-home-mobile.png new file mode 100644 index 00000000..1a06f613 Binary files /dev/null and b/.codex-home-mobile.png differ diff --git a/.env.local b/.env.local index 95fecc04..95f32753 100644 --- a/.env.local +++ b/.env.local @@ -50,6 +50,15 @@ ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS" RUST_SERVER_TARGET="http://127.0.0.1:3100" GENARRATIVE_API_TARGET="http://127.0.0.1:3100" +GENARRATIVE_SPACETIME_SERVER_URL="http://127.0.0.1:3101" +GENARRATIVE_SPACETIME_DATABASE="xushi-p4wfr" +GENARRATIVE_SPACETIME_TOKEN="" + GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="https://maincloud.spacetimedb.com" GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr" -GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwMUtQRzVXQlhTTjVCRDAyTjBNSlNONFJCNyIsImlzcyI6Imh0dHBzOi8vYXV0aC5zcGFjZXRpbWVkYi5jb20iLCJhdWQiOiJzcGFjZXRpbWVkYiIsImlhdCI6MTc3NjUxMjAyMiwiZXhwIjoxODM5NTg0MDIyfQ.UguSQDajalekrqs9oiUqLZiWjWK7VTgMQfdLVOhBQZpKX0VYUhNMSok9oBMJ4X655_NxV5TUUXZ4ON4HSJZrMMPc9aZyhS1b3i36vqI_zMPwLrAgfb1MqY5o0wNFl6Y0m0UQ3nsu7ZYxmxxgzF4My7So0Pv75QfXFS3-Uq1-QyO7lCxxgQ6vySbP_PEr7FZJsdPkNvAfP7mTaUh0yaV6SI7jXBsZ_mfdcWtElCNuvR9J3hvfAbx1qyeTgCJtgH4kNhiEOEIAYEFMEQkXd4rdLszmEgtlFubYZPsbMgqeZKx73feU6eGxlYhyPiRHF4AdosIfk3x2MAm_WzOd3efXDQ" +GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="" + +# admin +GENARRATIVE_ADMIN_USERNAME=admin +GENARRATIVE_ADMIN_PASSWORD=123456 +ADMIN_API_TARGET=http://127.0.0.1:8082 \ No newline at end of file diff --git a/apps/admin-web/index.html b/apps/admin-web/index.html new file mode 100644 index 00000000..fa118e41 --- /dev/null +++ b/apps/admin-web/index.html @@ -0,0 +1,12 @@ + + + + + + 百梦后台 + + +
+ + + diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json new file mode 100644 index 00000000..883c0688 --- /dev/null +++ b/apps/admin-web/package.json @@ -0,0 +1,24 @@ +{ + "name": "@genarrative/admin-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "node ../../scripts/admin-web-build.mjs build", + "typecheck": "node ../../scripts/admin-web-build.mjs typecheck", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@vitejs/plugin-react": "^5.0.4", + "lucide-react": "^0.546.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^6.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "typescript": "~5.8.2" + } +} diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts new file mode 100644 index 00000000..c4203bfd --- /dev/null +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -0,0 +1,238 @@ +import type { + AdminDebugHttpRequest, + AdminDebugHttpResponse, + AdminDisableProfileRedeemCodeRequest, + AdminLoginResponse, + AdminMeResponse, + AdminOverviewResponse, + AdminUpsertProfileInviteCodeRequest, + AdminUpsertProfileRedeemCodeRequest, + ApiErrorEnvelope, + ApiMeta, + ApiSuccessEnvelope, + ProfileInviteCodeAdminResponse, + ProfileRedeemCodeAdminResponse, +} from './adminApiTypes'; + +const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope'; +const ADMIN_API_BASE_URL = normalizeBaseUrl( + import.meta.env.VITE_ADMIN_API_BASE_URL ?? '', +); + +interface AdminRequestOptions { + method?: string; + token?: string; + body?: unknown; + headers?: Record; + signal?: AbortSignal; +} + +export class AdminApiError extends Error { + status: number; + code: string; + details: Record | null; + meta: ApiMeta | null; + responseText: string; + + constructor(params: { + message: string; + status: number; + code?: string; + details?: Record | null; + meta?: ApiMeta | null; + responseText?: string; + }) { + super(params.message); + this.name = 'AdminApiError'; + this.status = params.status; + this.code = params.code ?? 'ADMIN_API_ERROR'; + this.details = params.details ?? null; + this.meta = params.meta ?? null; + this.responseText = params.responseText ?? ''; + } +} + +export function isAdminApiError(error: unknown): error is AdminApiError { + return error instanceof AdminApiError; +} + +export function formatAdminApiError(error: unknown) { + if (isAdminApiError(error)) { + return error.message; + } + + if (error instanceof Error && error.message.trim()) { + return error.message; + } + + return '请求失败'; +} + +export async function request( + path: string, + options: AdminRequestOptions = {}, +): Promise { + const method = options.method ?? 'GET'; + const headers: Record = { + Accept: 'application/json', + [API_RESPONSE_ENVELOPE_HEADER]: 'v1', + ...(options.headers ?? {}), + }; + + const token = options.token?.trim(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const init: RequestInit = { + method, + headers, + signal: options.signal, + }; + + if (typeof options.body !== 'undefined') { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(options.body); + } + + const response = await fetch(buildRequestUrl(path), init); + const responseText = await response.text(); + const payload = parseJsonResponse(responseText); + + if (!response.ok) { + throw buildAdminApiError(response, payload, responseText); + } + + return unwrapSuccessPayload(payload); +} + +export function loginAdmin(username: string, password: string) { + return request('/admin/api/login', { + method: 'POST', + body: {username, password}, + }); +} + +export function getAdminMe(token: string) { + return request('/admin/api/me', {token}); +} + +export function getAdminOverview(token: string) { + return request('/admin/api/overview', {token}); +} + +export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) { + return request('/admin/api/debug/http', { + method: 'POST', + token, + body: payload, + }); +} + +export function upsertProfileRedeemCode( + token: string, + payload: AdminUpsertProfileRedeemCodeRequest, +) { + return request( + '/admin/api/profile/redeem-codes', + { + method: 'POST', + token, + body: payload, + }, + ); +} + +export function upsertProfileInviteCode( + token: string, + payload: AdminUpsertProfileInviteCodeRequest, +) { + return request( + '/admin/api/profile/invite-codes', + { + method: 'POST', + token, + body: payload, + }, + ); +} + +export function disableProfileRedeemCode( + token: string, + payload: AdminDisableProfileRedeemCodeRequest, +) { + return request( + '/admin/api/profile/redeem-codes/disable', + { + method: 'POST', + token, + body: payload, + }, + ); +} + +function normalizeBaseUrl(value: string) { + return value.trim().replace(/\/+$/, ''); +} + +function buildRequestUrl(path: string) { + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${ADMIN_API_BASE_URL}${normalizedPath}`; +} + +function parseJsonResponse(responseText: string): unknown { + if (!responseText.trim()) { + return null; + } + + try { + return JSON.parse(responseText) as unknown; + } catch { + return null; + } +} + +function unwrapSuccessPayload(payload: unknown): T { + if (isRecord(payload) && 'data' in payload) { + return (payload as ApiSuccessEnvelope).data as T; + } + + return payload as T; +} + +function buildAdminApiError( + response: Response, + payload: unknown, + responseText: string, +) { + const envelope = isRecord(payload) ? (payload as ApiErrorEnvelope) : null; + const errorPayload = envelope?.error; + const details = isRecord(errorPayload?.details) + ? errorPayload.details + : null; + const detailsMessage = + typeof details?.message === 'string' ? details.message.trim() : ''; + const payloadMessage = + typeof errorPayload?.message === 'string' ? errorPayload.message.trim() : ''; + const topLevelMessage = + typeof envelope?.message === 'string' ? envelope.message.trim() : ''; + const message = + detailsMessage || + payloadMessage || + topLevelMessage || + response.statusText || + `HTTP ${response.status}`; + + return new AdminApiError({ + message, + status: response.status, + code: errorPayload?.code, + details, + meta: envelope?.meta ?? null, + responseText, + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts new file mode 100644 index 00000000..a9201f87 --- /dev/null +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -0,0 +1,148 @@ +export interface ApiMeta { + apiVersion?: string; + requestId?: string; + routeVersion?: string; + operation?: string; + latencyMs?: number; + timestamp?: string; +} + +export interface ApiErrorPayload { + code?: string; + message?: string; + details?: { + message?: string; + [key: string]: unknown; + } | null; +} + +export interface ApiSuccessEnvelope { + ok?: boolean; + data?: T; + error?: ApiErrorPayload | null; + meta?: ApiMeta; +} + +export interface ApiErrorEnvelope { + ok?: boolean; + data?: unknown; + error?: ApiErrorPayload; + meta?: ApiMeta; + message?: string; +} + +export interface AdminSessionPayload { + subject: string; + username: string; + displayName: string; + roles: string[]; + issuedAt: string; + expiresAt: string; +} + +export interface AdminLoginResponse { + token: string; + admin: AdminSessionPayload; +} + +export interface AdminMeResponse { + admin: AdminSessionPayload; +} + +export interface AdminOverviewResponse { + service: AdminServiceOverviewPayload; + database: AdminDatabaseOverviewPayload; +} + +export interface AdminServiceOverviewPayload { + bindHost: string; + bindPort: number; + jwtIssuer: string; + adminEnabled: boolean; + spacetimeServerUrl: string; + spacetimeDatabase: string; +} + +export interface AdminDatabaseOverviewPayload { + databaseIdentity: string | null; + ownerIdentity: string | null; + hostType: string | null; + schemaTableNames: string[]; + tableStats: AdminDatabaseTableStatPayload[]; + fetchErrors: string[]; +} + +export interface AdminDatabaseTableStatPayload { + tableName: string; + rowCount: number | null; + errorMessage: string | null; +} + +export interface AdminDebugHeaderInput { + name: string; + value: string; +} + +export type AdminDebugHttpMethod = + | 'GET' + | 'POST' + | 'PUT' + | 'PATCH' + | 'DELETE'; + +export interface AdminDebugHttpRequest { + method: AdminDebugHttpMethod; + path: string; + headers?: AdminDebugHeaderInput[]; + body?: string; +} + +export interface AdminDebugHttpResponse { + status: number; + statusText: string; + headers: AdminDebugHeaderInput[]; + bodyText: string; + bodyJson: unknown | null; +} + +export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private'; + +export interface AdminUpsertProfileRedeemCodeRequest { + code: string; + mode: ProfileRedeemCodeMode; + rewardPoints: number; + maxUses: number; + enabled: boolean; + allowedUserIds: string[]; + allowedPublicUserCodes: string[]; +} + +export interface AdminUpsertProfileInviteCodeRequest { + inviteCode: string; + metadata?: Record; +} + +export interface AdminDisableProfileRedeemCodeRequest { + code: string; +} + +export interface ProfileRedeemCodeAdminResponse { + code: string; + mode: ProfileRedeemCodeMode; + rewardPoints: number; + maxUses: number; + globalUsedCount: number; + enabled: boolean; + allowedUserIds: string[]; + createdBy: string; + createdAt: string; + updatedAt: string; +} + +export interface ProfileInviteCodeAdminResponse { + userId: string; + inviteCode: string; + metadata: Record; + createdAt: string; + updatedAt: string; +} diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx new file mode 100644 index 00000000..498db96f --- /dev/null +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -0,0 +1,177 @@ +import {useCallback, useEffect, useState} from 'react'; + +import { + formatAdminApiError, + getAdminMe, + isAdminApiError, + loginAdmin, +} from '../api/adminApiClient'; +import type { + AdminSessionPayload, + ProfileInviteCodeAdminResponse, + ProfileRedeemCodeAdminResponse, +} from '../api/adminApiTypes'; +import { + clearStoredAdminToken, + getStoredAdminToken, + setStoredAdminToken, +} from '../auth/adminAuthStore'; +import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage'; +import {AdminInviteCodePage} from '../pages/AdminInviteCodePage'; +import {AdminLoginPage} from '../pages/AdminLoginPage'; +import {AdminOverviewPage} from '../pages/AdminOverviewPage'; +import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage'; +import {AdminShell} from './AdminShell'; +import type {AdminRouteId} from './adminRoutes'; +import {resolveAdminRoute, routeHash} from './adminRoutes'; + +type SessionStatus = 'checking' | 'guest' | 'authenticated'; + +export function AdminApp() { + const [status, setStatus] = useState('checking'); + const [admin, setAdmin] = useState(null); + const [token, setToken] = useState(''); + const [routeId, setRouteId] = useState(() => + resolveAdminRoute(window.location.hash), + ); + const [loginNotice, setLoginNotice] = useState(''); + // 兑换码页会随页签切换卸载,最近操作记录需要放在会话层保留。 + const [redeemResult, setRedeemResult] = + useState(null); + const [inviteResult, setInviteResult] = + useState(null); + + const clearSession = useCallback((message = '') => { + clearStoredAdminToken(); + setToken(''); + setAdmin(null); + setRedeemResult(null); + setInviteResult(null); + setStatus('guest'); + setLoginNotice(message); + }, []); + + useEffect(() => { + let isMounted = true; + const storedToken = getStoredAdminToken(); + + if (!storedToken) { + setStatus('guest'); + return; + } + + void getAdminMe(storedToken) + .then((response) => { + if (!isMounted) { + return; + } + + setToken(storedToken); + setAdmin(response.admin); + setStatus('authenticated'); + }) + .catch((error: unknown) => { + if (!isMounted) { + return; + } + + clearStoredAdminToken(); + setToken(''); + setAdmin(null); + setStatus('guest'); + setLoginNotice( + isAdminApiError(error) && error.status === 401 + ? '登录状态已失效' + : formatAdminApiError(error), + ); + }); + + return () => { + isMounted = false; + }; + }, []); + + useEffect(() => { + const handleHashChange = () => { + setRouteId(resolveAdminRoute(window.location.hash)); + }; + + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, []); + + const handleRouteChange = useCallback((nextRouteId: AdminRouteId) => { + setRouteId(nextRouteId); + const nextHash = routeHash(nextRouteId); + if (window.location.hash !== nextHash) { + window.location.hash = nextHash; + } + }, []); + + const handleLogin = useCallback(async (username: string, password: string) => { + const response = await loginAdmin(username, password); + setStoredAdminToken(response.token); + setToken(response.token); + setAdmin(response.admin); + setRedeemResult(null); + setInviteResult(null); + setLoginNotice(''); + setStatus('authenticated'); + }, []); + + const handleUnauthorized = useCallback( + (message = '登录状态已失效') => { + clearSession(message); + }, + [clearSession], + ); + + const handleLogout = useCallback(() => { + clearSession(''); + }, [clearSession]); + + if (status === 'checking') { + return ( +
+
+ 正在校验会话 +
+ ); + } + + if (status === 'guest' || !admin || !token) { + return ; + } + + return ( + + {routeId === 'overview' ? ( + + ) : null} + {routeId === 'debug' ? ( + + ) : null} + {routeId === 'redeem' ? ( + + ) : null} + {routeId === 'invite' ? ( + + ) : null} + + ); +} diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx new file mode 100644 index 00000000..47a7c0d0 --- /dev/null +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -0,0 +1,110 @@ +import { + Bug, + LayoutDashboard, + LogOut, + ShieldCheck, + TicketCheck, + TicketPercent, +} from 'lucide-react'; +import type {ReactNode} from 'react'; + +import type {AdminSessionPayload} from '../api/adminApiTypes'; +import type {AdminRouteId} from './adminRoutes'; +import {adminRoutes} from './adminRoutes'; + +interface AdminShellProps { + admin: AdminSessionPayload; + routeId: AdminRouteId; + children: ReactNode; + onRouteChange: (routeId: AdminRouteId) => void; + onLogout: () => void; +} + +const routeIcons = { + overview: LayoutDashboard, + debug: Bug, + redeem: TicketPercent, + invite: TicketCheck, +} satisfies Record; + +export function AdminShell({ + admin, + routeId, + children, + onRouteChange, + onLogout, +}: AdminShellProps) { + return ( +
+ + +
+
+
+ {admin.displayName || admin.username} + {admin.roles.join(' / ')} +
+ +
+ +
{children}
+
+ + +
+ ); +} diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts new file mode 100644 index 00000000..ee9ba760 --- /dev/null +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -0,0 +1,30 @@ +export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite'; + +export interface AdminRouteDefinition { + id: AdminRouteId; + label: string; + hash: string; +} + +export const adminRoutes: AdminRouteDefinition[] = [ + {id: 'overview', label: '总览', hash: '#overview'}, + {id: 'debug', label: 'API 调试', hash: '#debug'}, + {id: 'redeem', label: '兑换码', hash: '#redeem'}, + {id: 'invite', label: '邀请码', hash: '#invite'}, +]; + +export function resolveAdminRoute(hash: string): AdminRouteId { + const normalizedHash = hash.trim().toLowerCase(); + return ( + adminRoutes.find((route) => route.hash === normalizedHash)?.id ?? + 'overview' + ); +} + +export function routeHash(routeId: AdminRouteId) { + return ( + adminRoutes.find((route) => route.id === routeId)?.hash ?? + adminRoutes[0]?.hash ?? + '#overview' + ); +} diff --git a/apps/admin-web/src/auth/adminAuthStore.ts b/apps/admin-web/src/auth/adminAuthStore.ts new file mode 100644 index 00000000..f7e02d4d --- /dev/null +++ b/apps/admin-web/src/auth/adminAuthStore.ts @@ -0,0 +1,34 @@ +export const ADMIN_TOKEN_STORAGE_KEY = 'genarrative_admin_token'; + +// 管理员 token 与玩家 token 分开保存,避免后台请求误复用玩家登录态。 +export function getStoredAdminToken() { + if (!canUseLocalStorage()) { + return ''; + } + + return window.localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY)?.trim() || ''; +} + +export function setStoredAdminToken(token: string) { + if (!canUseLocalStorage()) { + return; + } + + const nextToken = token.trim(); + if (nextToken) { + window.localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, nextToken); + return; + } + + window.localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY); +} + +export function clearStoredAdminToken() { + setStoredAdminToken(''); +} + +function canUseLocalStorage() { + return ( + typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' + ); +} diff --git a/apps/admin-web/src/main.tsx b/apps/admin-web/src/main.tsx new file mode 100644 index 00000000..339dc360 --- /dev/null +++ b/apps/admin-web/src/main.tsx @@ -0,0 +1,18 @@ +import './styles/admin.css'; + +import {StrictMode} from 'react'; +import {createRoot} from 'react-dom/client'; + +import {AdminApp} from './app/AdminApp'; + +const rootElement = document.getElementById('root'); + +if (!rootElement) { + throw new Error('Missing #root container'); +} + +createRoot(rootElement).render( + + + , +); diff --git a/apps/admin-web/src/pages/AdminDebugHttpPage.tsx b/apps/admin-web/src/pages/AdminDebugHttpPage.tsx new file mode 100644 index 00000000..a5ac8ad1 --- /dev/null +++ b/apps/admin-web/src/pages/AdminDebugHttpPage.tsx @@ -0,0 +1,214 @@ +import {Plus, Send, Trash2} from 'lucide-react'; +import {FormEvent, useMemo, useState} from 'react'; + +import {debugAdminHttp} from '../api/adminApiClient'; +import type { + AdminDebugHeaderInput, + AdminDebugHttpMethod, + AdminDebugHttpResponse, +} from '../api/adminApiTypes'; +import {formatUnknownJson, handlePageError} from './pageUtils'; + +interface AdminDebugHttpPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +const httpMethods: AdminDebugHttpMethod[] = [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', +]; + +export function AdminDebugHttpPage({ + token, + onUnauthorized, +}: AdminDebugHttpPageProps) { + const [method, setMethod] = useState('GET'); + const [path, setPath] = useState('/healthz'); + const [body, setBody] = useState(''); + const [headers, setHeaders] = useState([]); + const [result, setResult] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const jsonPreview = useMemo( + () => formatUnknownJson(result?.bodyJson), + [result?.bodyJson], + ); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (isSubmitting) { + return; + } + + setErrorMessage(''); + setIsSubmitting(true); + try { + const response = await debugAdminHttp(token, { + method, + path: path.trim(), + headers: headers.filter((header) => header.name.trim()), + body, + }); + setResult(response); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+

API 调试

+

受控同源请求

+
+
+ +
+
+
+ + +
+ +
+
+ Headers + +
+
+ {headers.map((header, index) => ( +
+ + setHeaders((current) => + current.map((item, itemIndex) => + itemIndex === index + ? {...item, name: event.target.value} + : item, + ), + ) + } + /> + + setHeaders((current) => + current.map((item, itemIndex) => + itemIndex === index + ? {...item, value: event.target.value} + : item, + ), + ) + } + /> + +
+ ))} +
+
+ + - -
- -
-
-
-
- - -
-
-
-

数据库概览

-

读取当前服务配置和 SpacetimeDB 数据库真相。

-
-
-
- -
-
-
-
-
-
-
- -
-
-

调试结果

-

返回状态、响应头和内容预览。

-
-
-
-
尚未执行调试请求。
-
-
-
-
- - - - - -"#; - #[cfg(test)] mod tests { - use super::{build_body_preview, build_debug_base_url, normalize_debug_path, trim_preview}; + use super::{ + build_body_preview, build_debug_base_url, build_spacetime_schema_url, + is_safe_spacetime_table_name, normalize_debug_path, normalize_table_count_error, + parse_spacetime_sql_count_response, trim_preview, + }; use axum::{http::StatusCode, response::IntoResponse}; + use serde_json::json; #[test] fn normalize_debug_path_rejects_absolute_url() { @@ -1161,6 +699,123 @@ mod tests { assert_eq!(trim_preview(&text).chars().count(), 4000); } + #[test] + fn build_spacetime_schema_url_includes_required_version_query() { + let url = build_spacetime_schema_url("http://127.0.0.1:3101", "xushi-p4wfr"); + + assert_eq!( + url, + "http://127.0.0.1:3101/v1/database/xushi-p4wfr/schema?version=9" + ); + } + + #[test] + fn is_safe_spacetime_table_name_accepts_schema_identifiers() { + assert!(is_safe_spacetime_table_name("runtime_setting")); + assert!(is_safe_spacetime_table_name("_private_table")); + assert!(is_safe_spacetime_table_name("AiTaskStage2")); + } + + #[test] + fn is_safe_spacetime_table_name_rejects_sql_fragments() { + assert!(!is_safe_spacetime_table_name("")); + assert!(!is_safe_spacetime_table_name("bad-name")); + assert!(!is_safe_spacetime_table_name("1bad")); + assert!(!is_safe_spacetime_table_name("runtime_setting;DROP")); + } + + #[test] + fn normalize_table_count_error_hides_private_table_http_noise() { + let error = "HTTP 400:no such table: `runtime_setting`. If the table exists, it may be marked private."; + + assert_eq!( + normalize_table_count_error(error), + "不可统计(private 或当前身份不可见)" + ); + } + + #[test] + fn normalize_table_count_error_keeps_other_errors() { + let error = "SQL 请求失败:connection refused"; + + assert_eq!(normalize_table_count_error(error), error); + } + + #[test] + fn parse_spacetime_sql_count_response_accepts_statement_array_rows() { + let payload = json!([ + { + "schema": { + "elements": [ + { + "name": { + "some": "row_count" + }, + "algebraic_type": { + "U64": [] + } + } + ] + }, + "rows": [[7]], + "total_duration_micros": 116, + "stats": { + "rows_inserted": 0, + "rows_deleted": 0, + "rows_updated": 0 + } + } + ]); + + let count = + parse_spacetime_sql_count_response(payload).expect("statement array should parse"); + + assert_eq!(count, 7); + } + + #[test] + fn parse_spacetime_sql_count_response_uses_schema_column_index() { + let payload = json!([ + { + "schema": { + "elements": [ + { + "name": { + "some": "table_name" + } + }, + { + "name": { + "some": "row_count" + } + } + ] + }, + "rows": [["runtime_setting", "12"]] + } + ]); + + let count = + parse_spacetime_sql_count_response(payload).expect("schema column index should parse"); + + assert_eq!(count, 12); + } + + #[test] + fn parse_spacetime_sql_count_response_keeps_object_row_compatibility() { + let payload = json!({ + "rows": [ + { + "row_count": "3" + } + ] + }); + + let count = parse_spacetime_sql_count_response(payload).expect("object row should parse"); + + assert_eq!(count, 3); + } + #[test] fn build_body_preview_handles_utf8() { let preview = build_body_preview("后台测试".as_bytes()); diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 57191bc2..bd9bf73d 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1,7 +1,7 @@ use axum::{ Router, body::Body, - extract::Extension, + extract::{DefaultBodyLimit, Extension}, http::Request, middleware, routing::{delete, get, post}, @@ -13,10 +13,7 @@ use tower_http::{ use tracing::{Level, Span, error, info, info_span, warn}; use crate::{ - admin::{ - admin_console_page, admin_debug_http, admin_login, admin_me, admin_overview, - require_admin_auth, - }, + admin::{admin_debug_http, admin_login, admin_me, admin_overview, require_admin_auth}, ai_tasks::{ append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage, complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage, @@ -34,7 +31,8 @@ use crate::{ auth_sessions::auth_sessions, big_fish::{ create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run, - get_big_fish_session, get_big_fish_works, list_big_fish_gallery, record_big_fish_play, + get_big_fish_session, get_big_fish_works, list_big_fish_gallery, + record_big_fish_gallery_like, record_big_fish_play, remix_big_fish_gallery_work, start_big_fish_run, stream_big_fish_message, submit_big_fish_input, submit_big_fish_message, }, @@ -57,8 +55,9 @@ use crate::{ get_custom_world_gallery_detail_by_code, get_custom_world_library, get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery, publish_custom_world_library_profile, put_custom_world_library_profile, - stream_custom_world_agent_message, submit_custom_world_agent_message, - unpublish_custom_world_library_profile, + record_custom_world_gallery_like, record_custom_world_gallery_play, + remix_custom_world_gallery_profile, stream_custom_world_agent_message, + submit_custom_world_agent_message, unpublish_custom_world_library_profile, }, custom_world_ai::{ generate_custom_world_cover_image, generate_custom_world_entity, @@ -71,15 +70,26 @@ use crate::{ login_options::auth_login_options, logout::logout, logout_all::logout_all, + match3d::{ + click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session, + delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up, + get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works, + list_match3d_gallery, publish_match3d_work, put_match3d_work, restart_match3d_run, + start_match3d_run, stop_match3d_run, stream_match3d_agent_message, + submit_match3d_agent_message, + }, password_entry::password_entry, password_management::{change_password, reset_password}, phone_auth::{phone_login, send_phone_code}, + profile_identity::update_profile_identity, puzzle::{ - advance_puzzle_next_level, create_puzzle_agent_session, delete_puzzle_work, - drag_puzzle_piece_or_group, execute_puzzle_agent_action, get_puzzle_agent_session, - get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, - list_puzzle_gallery, put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message, - submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces, + advance_puzzle_next_level, claim_puzzle_work_point_incentive, create_puzzle_agent_session, + delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action, + get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run, + get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work, + record_puzzle_gallery_like, remix_puzzle_gallery_work, start_puzzle_run, + stream_puzzle_agent_message, submit_puzzle_agent_message, submit_puzzle_leaderboard, + swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop, }, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, @@ -95,10 +105,10 @@ use crate::{ }, runtime_inventory::get_runtime_inventory_state, runtime_profile::{ - admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code, - create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats, - get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger, - redeem_profile_referral_invite_code, redeem_profile_reward_code, + admin_disable_profile_redeem_code, admin_upsert_profile_invite_code, + admin_upsert_profile_redeem_code, create_profile_recharge_order, get_profile_dashboard, + get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center, + get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code, }, runtime_save::{ delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, @@ -116,12 +126,13 @@ use crate::{ wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login}, }; +const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; + // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 pub fn build_router(state: AppState) -> Router { let slow_request_threshold_ms = state.config.slow_request_threshold_ms; Router::new() - .route("/admin", get(admin_console_page)) .route("/admin/api/login", post(admin_login)) .route( "/admin/api/me", @@ -158,6 +169,13 @@ pub fn build_router(state: AppState) -> Router { require_admin_auth, )), ) + .route( + "/admin/api/profile/invite-codes", + post(admin_upsert_profile_invite_code).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) .route( "/healthz", get(|Extension(request_context): Extension<_>| async move { @@ -206,6 +224,12 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/profile/me", + axum::routing::patch(update_profile_identity).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) .route( "/api/auth/refresh", post(refresh_session).route_layer(middleware::from_fn_with_state( @@ -482,6 +506,27 @@ pub fn build_router(state: AppState) -> Router { "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}", get(get_custom_world_gallery_detail), ) + .route( + "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/remix", + post(remix_custom_world_gallery_profile).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/play", + post(record_custom_world_gallery_play).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/like", + post(record_custom_world_gallery_like).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/custom-world-gallery/by-code/{code}", get(get_custom_world_gallery_detail_by_code), @@ -594,6 +639,20 @@ pub fn build_router(state: AppState) -> Router { )), ) .route("/api/runtime/big-fish/gallery", get(list_big_fish_gallery)) + .route( + "/api/runtime/big-fish/gallery/{session_id}/remix", + post(remix_big_fish_gallery_work).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/big-fish/gallery/{session_id}/like", + post(record_big_fish_gallery_like).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/big-fish/works/{session_id}", delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state( @@ -637,12 +696,127 @@ pub fn build_router(state: AppState) -> Router { )), ) .route( - "/api/runtime/puzzle/agent/sessions", - post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state( + "/api/creation/match3d/sessions", + post(create_match3d_agent_session).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) + .route( + "/api/creation/match3d/sessions/{session_id}", + get(get_match3d_agent_session).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/match3d/sessions/{session_id}/messages", + post(submit_match3d_agent_message).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/match3d/sessions/{session_id}/messages/stream", + post(stream_match3d_agent_message).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/match3d/sessions/{session_id}/actions", + post(execute_match3d_agent_action).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/match3d/sessions/{session_id}/compile", + post(compile_match3d_agent_draft).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/match3d/works", + get(get_match3d_works).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/match3d/works/{profile_id}", + get(get_match3d_work_detail) + .patch(put_match3d_work) + .put(put_match3d_work) + .delete(delete_match3d_work) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/match3d/works/{profile_id}/publish", + post(publish_match3d_work).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route("/api/runtime/match3d/gallery", get(list_match3d_gallery)) + .route( + "/api/runtime/match3d/works/{profile_id}/runs", + post(start_match3d_run).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/match3d/runs/{run_id}", + get(get_match3d_run).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/match3d/runs/{run_id}/click", + post(click_match3d_item).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/match3d/runs/{run_id}/stop", + post(stop_match3d_run).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/match3d/runs/{run_id}/restart", + post(restart_match3d_run).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/match3d/runs/{run_id}/time-up", + post(finish_match3d_time_up).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/puzzle/agent/sessions", + post(create_puzzle_agent_session) + // 中文注释:拼图表单会携带单张参考图 Data URL,需只给该写入入口放宽 body 上限。 + .layer(DefaultBodyLimit::max( + PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/puzzle/agent/sessions/{session_id}", get(get_puzzle_agent_session).route_layer(middleware::from_fn_with_state( @@ -666,10 +840,15 @@ pub fn build_router(state: AppState) -> Router { ) .route( "/api/runtime/puzzle/agent/sessions/{session_id}/actions", - post(execute_puzzle_agent_action).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(execute_puzzle_agent_action) + // 中文注释:生成草稿/重新出图会复用 referenceImageSrc,避免默认 2MB JSON limit 拦截。 + .layer(DefaultBodyLimit::max( + PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/runtime/puzzle/works", @@ -688,11 +867,32 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/puzzle/works/{profile_id}/point-incentive/claim", + post(claim_puzzle_work_point_incentive).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route("/api/runtime/puzzle/gallery", get(list_puzzle_gallery)) .route( "/api/runtime/puzzle/gallery/{profile_id}", get(get_puzzle_gallery_detail), ) + .route( + "/api/runtime/puzzle/gallery/{profile_id}/remix", + post(remix_puzzle_gallery_work).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/puzzle/gallery/{profile_id}/like", + post(record_puzzle_gallery_like).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/puzzle/runs", post(start_puzzle_run).route_layer(middleware::from_fn_with_state( @@ -728,6 +928,20 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/puzzle/runs/{run_id}/pause", + post(update_puzzle_run_pause).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/puzzle/runs/{run_id}/props", + post(use_puzzle_runtime_prop).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/puzzle/runs/{run_id}/leaderboard", post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state( @@ -1041,6 +1255,30 @@ mod tests { .await } + fn sign_test_user_token( + state: &AppState, + user: &module_auth::AuthUser, + session_id: &str, + ) -> String { + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: user.id.clone(), + session_id: session_id.to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: user.token_version, + phone_verified: false, + binding_status: BindingStatus::Active, + display_name: Some(user.display_name.clone()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } + async fn password_login_request( app: Router, phone_number: &str, @@ -1424,6 +1662,88 @@ mod tests { ); } + #[tokio::test] + async fn puzzle_agent_actions_accept_reference_image_body_above_default_limit() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138024", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_puzzle_reference_body"); + let app = build_router(state); + let reference_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); + let request_body = serde_json::json!({ + "action": "unsupported_large_reference_test", + "referenceImageSrc": reference_image_src, + }) + .to_string(); + assert!(request_body.len() > 2 * 1024 * 1024); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/puzzle/agent/sessions/puzzle-session-large/actions") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(request_body)) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let body_text = String::from_utf8_lossy(&body); + assert!( + body_text.contains("unsupported_large_reference_test"), + "handler should parse the oversized reference payload before rejecting the action: {body_text}" + ); + assert!(!body_text.contains("length limit exceeded")); + } + + #[tokio::test] + async fn puzzle_agent_session_creation_accepts_reference_image_body_above_default_limit() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138025", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_puzzle_form_reference_body"); + let app = build_router(state); + let request_body = format!( + "{{\"seedText\":\"大参考图拼图\",\"pictureDescription\":\"一张用于验证 body limit 的参考图。\",\"referenceImageSrc\":\"data:image/png;base64,{}\"", + "A".repeat(3 * 1024 * 1024) + ); + assert!(request_body.len() > 2 * 1024 * 1024); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/puzzle/agent/sessions") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(request_body)) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let body_text = String::from_utf8_lossy(&body); + assert!( + body_text.contains("EOF") || body_text.contains("expected"), + "handler should parse the oversized form payload before rejecting malformed JSON: {body_text}" + ); + assert!(!body_text.contains("length limit exceeded")); + } + #[tokio::test] async fn password_entry_rejects_unknown_phone_without_registration() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -1494,6 +1814,10 @@ mod tests { payload["user"]["loginMethod"], Value::String("password".to_string()) ); + assert_eq!( + payload["user"]["createdAt"], + Value::String(seed_user.created_at) + ); assert!(payload["token"].as_str().is_some()); } @@ -1773,6 +2097,9 @@ mod tests { payload["user"]["phoneNumberMasked"], Value::String("138****8000".to_string()) ); + assert!(payload["user"]["createdAt"].as_str().is_some()); + assert_eq!(payload["created"], Value::Bool(true)); + assert!(payload["referral"].is_null()); } #[tokio::test] @@ -1879,6 +2206,175 @@ mod tests { serde_json::from_slice(&second_body).expect("second login payload should be json"); assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]); + assert_eq!(first_payload["created"], Value::Bool(true)); + assert_eq!(second_payload["created"], Value::Bool(false)); + assert!(second_payload["referral"].is_null()); + } + + #[tokio::test] + async fn phone_login_invite_code_failure_does_not_block_created_user() { + let config = AppConfig { + sms_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let send_code_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13600136000", + "scene": "login" + }) + .to_string(), + )) + .expect("send code request should build"), + ) + .await + .expect("send code request should succeed"); + assert_eq!(send_code_response.status(), StatusCode::OK); + + let login_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/login") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13600136000", + "code": "123456", + "inviteCode": "SPRING2026" + }) + .to_string(), + )) + .expect("login request should build"), + ) + .await + .expect("login request should succeed"); + + assert_eq!(login_response.status(), StatusCode::OK); + let body = login_response + .into_body() + .collect() + .await + .expect("login body should collect") + .to_bytes(); + let payload: Value = serde_json::from_slice(&body).expect("login payload should be json"); + + assert!(payload["token"].as_str().is_some()); + assert_eq!(payload["created"], Value::Bool(true)); + assert_eq!(payload["referral"]["ok"], Value::Bool(false)); + assert_eq!( + payload["referral"]["message"], + Value::String("邀请码无效,已继续注册".to_string()) + ); + } + + #[tokio::test] + async fn phone_login_existing_user_ignores_invite_code() { + let config = AppConfig { + sms_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let first_send_code_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13500135000", + "scene": "login" + }) + .to_string(), + )) + .expect("send code request should build"), + ) + .await + .expect("send code request should succeed"); + assert_eq!(first_send_code_response.status(), StatusCode::OK); + + let first_login_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/login") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13500135000", + "code": "123456" + }) + .to_string(), + )) + .expect("first login request should build"), + ) + .await + .expect("first login request should succeed"); + assert_eq!(first_login_response.status(), StatusCode::OK); + + let second_send_code_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13500135000", + "scene": "login" + }) + .to_string(), + )) + .expect("send code request should build"), + ) + .await + .expect("send code request should succeed"); + assert_eq!(second_send_code_response.status(), StatusCode::OK); + + let second_login_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/login") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13500135000", + "code": "123456", + "inviteCode": "SPRING2026" + }) + .to_string(), + )) + .expect("second login request should build"), + ) + .await + .expect("second login request should succeed"); + + assert_eq!(second_login_response.status(), StatusCode::OK); + let body = second_login_response + .into_body() + .collect() + .await + .expect("second login body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("second login payload should be json"); + + assert_eq!(payload["created"], Value::Bool(false)); + assert!(payload["referral"].is_null()); } #[tokio::test] @@ -3094,6 +3590,23 @@ mod tests { ); } + #[tokio::test] + async fn admin_page_route_is_not_mounted() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .uri("/admin") + .body(Body::empty()) + .expect("admin page request should build"), + ) + .await + .expect("admin page request should succeed"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + #[tokio::test] async fn admin_login_returns_token_when_configured() { let mut config = AppConfig::default(); diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 13d9485a..18b2ef8f 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -29,7 +29,7 @@ where } } -/// 资产操作统一预扣叙世币;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。 +/// 资产操作统一预扣光点;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。 async fn consume_asset_operation_points( state: &AppState, owner_user_id: &str, @@ -79,15 +79,20 @@ async fn refund_asset_operation_points( asset_kind, asset_id, error = %error, - "资产操作失败后的叙世币退款失败" + "资产操作失败后的光点退款失败" ); } } pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError { + let message = error.to_string(); + tracing::warn!( + provider = "profile-wallet", + error = %message, + "资产操作光点预扣失败" + ); let status = match &error { - SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, - SpacetimeClientError::Procedure(message) if message.contains("叙世币余额不足") => { + SpacetimeClientError::Procedure(message) if message.contains("光点余额不足") => { StatusCode::CONFLICT } _ => StatusCode::BAD_GATEWAY, @@ -95,7 +100,7 @@ pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> A AppError::from_status(status).with_details(json!({ "provider": "profile-wallet", - "message": error.to_string(), + "message": message, })) } diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index b93e642c..14ab53af 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -23,8 +23,8 @@ use shared_contracts::assets::{ use spacetime_client::SpacetimeClientError; use crate::{ - api_response::json_success_body, http_error::AppError, platform_errors::map_oss_error, - request_context::RequestContext, state::AppState, + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + platform_errors::map_oss_error, request_context::RequestContext, state::AppState, }; // 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。 @@ -119,6 +119,7 @@ pub async fn get_asset_read_url( pub async fn get_asset_history( State(state): State, Extension(request_context): Extension, + Extension(authenticated): Extension, Query(query): Query, ) -> Result, AppError> { let asset_kind = query.kind.trim().to_string(); @@ -133,18 +134,23 @@ pub async fn get_asset_history( let entries = state .spacetime_client() - .list_asset_history(module_assets::AssetHistoryListInput { - asset_kind, - limit: query.limit.unwrap_or(120).clamp(1, 120), - }) + .list_asset_history(build_asset_history_list_input(asset_kind, query.limit)) .await .map_err(map_confirm_asset_object_error)?; + let owner_user_id = authenticated.claims().user_id().to_string(); Ok(json_success_body( Some(&request_context), AssetHistoryListResponse { assets: entries .into_iter() + // 中文注释:Maincloud 旧 wasm 的历史素材 procedure 仍按类型返回,HTTP 门面必须兜底做账号隔离。 + .filter(|entry| { + is_asset_history_owned_by( + entry.owner_user_id.as_deref(), + owner_user_id.as_str(), + ) + }) .map(|entry| AssetHistoryEntryPayload { owner_label: format_asset_owner_label(entry.owner_user_id.as_deref()), asset_object_id: entry.asset_object_id, @@ -296,6 +302,25 @@ fn is_supported_asset_history_kind(asset_kind: &str) -> bool { SUPPORTED_ASSET_HISTORY_KINDS.contains(&asset_kind) } +fn is_asset_history_owned_by(entry_owner_user_id: Option<&str>, owner_user_id: &str) -> bool { + let owner_user_id = owner_user_id.trim(); + !owner_user_id.is_empty() + && entry_owner_user_id + .map(str::trim) + .filter(|value| !value.is_empty()) + == Some(owner_user_id) +} + +fn build_asset_history_list_input( + asset_kind: String, + limit: Option, +) -> module_assets::AssetHistoryListInput { + module_assets::AssetHistoryListInput { + asset_kind, + limit: limit.unwrap_or(120).clamp(1, 120), + } +} + fn supported_asset_history_kind_message() -> String { format!( "历史素材类型只支持 {}", @@ -480,6 +505,29 @@ mod tests { ); } + #[test] + fn asset_history_owner_filter_keeps_only_authenticated_owner_assets() { + assert!(super::is_asset_history_owned_by( + Some("user-current"), + "user-current" + )); + assert!(!super::is_asset_history_owned_by( + Some("user-other"), + "user-current" + )); + assert!(!super::is_asset_history_owned_by(None, "user-current")); + assert!(!super::is_asset_history_owned_by(Some("user-current"), "")); + } + + #[test] + fn asset_history_input_clamps_limit_for_spacetime_query() { + let input = + super::build_asset_history_list_input("puzzle_cover_image".to_string(), Some(240)); + + assert_eq!(input.asset_kind, "puzzle_cover_image"); + assert_eq!(input.limit, 120); + } + #[tokio::test] async fn direct_upload_ticket_returns_service_unavailable_when_oss_missing() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/auth_payload.rs b/server-rs/crates/api-server/src/auth_payload.rs index 0e55b2cb..cdd52b51 100644 --- a/server-rs/crates/api-server/src/auth_payload.rs +++ b/server-rs/crates/api-server/src/auth_payload.rs @@ -7,10 +7,12 @@ pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload { public_user_code: user.public_user_code, username: user.username, display_name: user.display_name, + avatar_url: user.avatar_url, phone_number_masked: user.phone_number_masked, login_method: user.login_method.as_str().to_string(), binding_status: user.binding_status.as_str().to_string(), wechat_bound: user.wechat_bound, + created_at: user.created_at, } } @@ -19,5 +21,6 @@ pub fn map_public_user_summary_payload(user: AuthUser) -> PublicUserSummaryPaylo id: user.id, public_user_code: user.public_user_code, display_name: user.display_name, + avatar_url: user.avatar_url, } } diff --git a/server-rs/crates/api-server/src/auth_public_user.rs b/server-rs/crates/api-server/src/auth_public_user.rs index c33c384d..3c921811 100644 --- a/server-rs/crates/api-server/src/auth_public_user.rs +++ b/server-rs/crates/api-server/src/auth_public_user.rs @@ -20,7 +20,7 @@ pub async fn get_public_user_by_code( .get_user_by_public_user_code(&code) .map_err(map_public_user_search_error)? .ok_or_else(|| { - AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应叙世号用户") + AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应百梦号用户") })?; Ok(json_success_body( @@ -60,12 +60,15 @@ pub async fn get_public_user_by_id( fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppError { match error { module_auth::PasswordEntryError::InvalidPublicUserCode => { - AppError::from_status(StatusCode::BAD_REQUEST).with_message("叙世号格式不正确") + AppError::from_status(StatusCode::BAD_REQUEST).with_message("百梦号格式不正确") } module_auth::PasswordEntryError::Store(_) | module_auth::PasswordEntryError::PasswordHash(_) | module_auth::PasswordEntryError::InvalidPhoneNumber | module_auth::PasswordEntryError::InvalidPasswordLength + | module_auth::PasswordEntryError::InvalidDisplayName + | module_auth::PasswordEntryError::InvalidAvatarDataUrl + | module_auth::PasswordEntryError::EmptyProfileUpdate | module_auth::PasswordEntryError::InvalidCredentials | module_auth::PasswordEntryError::UserNotFound => { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index dc14147d..488991aa 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -34,10 +34,11 @@ use spacetime_client::{ BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord, - BigFishInputSubmitRecordInput, BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, - BigFishPlayReportRecordInput, BigFishRunStartRecordInput, BigFishRuntimeEntityRecord, - BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord, BigFishSessionCreateRecordInput, - BigFishSessionRecord, BigFishVector2Record, BigFishWorkSummaryRecord, SpacetimeClientError, + BigFishInputSubmitRecordInput, BigFishLevelBlueprintRecord, BigFishLikeReportRecordInput, + BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRunStartRecordInput, + BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord, + BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record, + BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, SpacetimeClientError, }; use tokio::time::sleep; @@ -63,6 +64,7 @@ use crate::{ platform_errors::map_oss_error, request_context::RequestContext, state::AppState, + work_author::resolve_work_author_by_user_id, }; pub async fn create_big_fish_session( @@ -147,7 +149,7 @@ pub async fn get_big_fish_works( BigFishWorksResponse { items: items .into_iter() - .map(map_big_fish_work_summary_response) + .map(|item| map_big_fish_work_summary_response(&state, item)) .collect(), }, )) @@ -179,7 +181,7 @@ pub async fn list_big_fish_gallery( BigFishWorksResponse { items: items .into_iter() - .map(map_big_fish_work_summary_response) + .map(|item| map_big_fish_work_summary_response(&state, item)) .collect(), }, )) @@ -206,7 +208,7 @@ pub async fn delete_big_fish_work( BigFishWorksResponse { items: items .into_iter() - .map(map_big_fish_work_summary_response) + .map(|item| map_big_fish_work_summary_response(&state, item)) .collect(), }, )) @@ -248,7 +250,7 @@ pub async fn record_big_fish_play( BigFishWorksResponse { items: items .into_iter() - .map(map_big_fish_work_summary_response) + .map(|item| map_big_fish_work_summary_response(&state, item)) .collect(), }, )) @@ -283,6 +285,37 @@ pub async fn start_big_fish_run( )) } +pub async fn record_big_fish_gallery_like( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &session_id, "sessionId")?; + + let items = state + .spacetime_client() + .record_big_fish_like(BigFishLikeReportRecordInput { + session_id, + user_id: authenticated.claims().user_id().to_string(), + liked_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + BigFishWorksResponse { + items: items + .into_iter() + .map(|item| map_big_fish_work_summary_response(&state, item)) + .collect(), + }, + )) +} + pub async fn get_big_fish_run( State(state): State, Path(run_id): Path, @@ -350,6 +383,36 @@ pub async fn submit_big_fish_input( )) } +pub async fn remix_big_fish_gallery_work( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &session_id, "sessionId")?; + + let session = state + .spacetime_client() + .remix_big_fish_work(BigFishWorkRemixRecordInput { + source_session_id: session_id, + target_session_id: build_prefixed_uuid_id("big-fish-session-"), + target_owner_user_id: authenticated.claims().user_id().to_string(), + welcome_message_id: build_prefixed_uuid_id("big-fish-message-"), + remixed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + BigFishSessionResponse { + session: map_big_fish_session_response(session), + }, + )) +} + pub async fn submit_big_fish_message( State(state): State, Path(session_id): Path, @@ -1038,24 +1101,33 @@ fn map_big_fish_agent_message_response( } fn map_big_fish_work_summary_response( + state: &AppState, item: BigFishWorkSummaryRecord, ) -> BigFishWorkSummaryResponse { + let author = resolve_work_author_by_user_id(state, &item.owner_user_id, None, None); BigFishWorkSummaryResponse { work_id: item.work_id, source_session_id: item.source_session_id, owner_user_id: item.owner_user_id, + author_display_name: author.display_name, title: item.title, subtitle: item.subtitle, summary: item.summary, cover_image_src: item.cover_image_src, status: item.status, updated_at: current_timestamp_micros_to_string(item.updated_at_micros), + published_at: item + .published_at_micros + .map(current_timestamp_micros_to_string), publish_ready: item.publish_ready, level_count: item.level_count, level_main_image_ready_count: item.level_main_image_ready_count, level_motion_ready_count: item.level_motion_ready_count, background_ready: item.background_ready, play_count: item.play_count, + remix_count: item.remix_count, + like_count: item.like_count, + recent_play_count_7d: item.recent_play_count_7d, } } diff --git a/server-rs/crates/api-server/src/big_fish_draft_compiler.rs b/server-rs/crates/api-server/src/big_fish_draft_compiler.rs index 89e6e984..a26566c6 100644 --- a/server-rs/crates/api-server/src/big_fish_draft_compiler.rs +++ b/server-rs/crates/api-server/src/big_fish_draft_compiler.rs @@ -7,6 +7,7 @@ use serde::Deserialize; use serde_json::Value as JsonValue; use crate::creation_agent_llm_turn::parse_json_response_text; +use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; const BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT: &str = r#"你是一个负责把“大鱼吃小鱼”玩法锚点编译成首版可实现草稿的中文玩法策划。 @@ -108,10 +109,15 @@ async fn request_big_fish_json_stage( empty_response_message: &str, ) -> Result { let response = llm_client - .request_text(LlmTextRequest::new(vec![ - LlmMessage::system(BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT), - LlmMessage::user(user_prompt), - ])) + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT), + LlmMessage::user(user_prompt), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api() + .with_web_search(true), + ) .await .map_err(|error| { BigFishDraftCompileError::new(format!("{debug_label} LLM 请求失败:{error}")) @@ -124,12 +130,16 @@ async fn request_big_fish_json_stage( Ok(value) => Ok(value), Err(_) => { let repaired = llm_client - .request_text(LlmTextRequest::new(vec![ - LlmMessage::system(BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT), - LlmMessage::user(format!( - "请把下面这段文本修复成单个合法 JSON 对象,不要补充额外解释:\n\n{text}" - )), - ])) + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT), + LlmMessage::user(format!( + "请把下面这段文本修复成单个合法 JSON 对象,不要补充额外解释:\n\n{text}" + )), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api(), + ) .await .map_err(|error| { BigFishDraftCompileError::new(format!( diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index deb69712..236059fe 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -1,4 +1,4 @@ -use std::{env, fs, net::SocketAddr, path::PathBuf}; +use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration}; use platform_llm::{ DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS, @@ -74,6 +74,7 @@ pub struct AppConfig { pub spacetime_database: String, pub spacetime_token: Option, pub spacetime_pool_size: u32, + pub spacetime_procedure_timeout: Duration, pub llm_provider: LlmProvider, pub llm_base_url: String, pub llm_api_key: Option, @@ -165,6 +166,7 @@ impl Default for AppConfig { spacetime_database: "genarrative-dev".to_string(), spacetime_token: None, spacetime_pool_size: 4, + spacetime_procedure_timeout: Duration::from_secs(30), llm_provider: LlmProvider::Ark, llm_base_url: DEFAULT_ARK_BASE_URL.to_string(), llm_api_key: None, @@ -436,6 +438,12 @@ impl AppConfig { { config.spacetime_pool_size = spacetime_pool_size; } + if let Some(spacetime_procedure_timeout_seconds) = + read_first_duration_seconds_env(&["GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS"]) + { + config.spacetime_procedure_timeout = + Duration::from_secs(spacetime_procedure_timeout_seconds); + } if let Some(llm_provider) = read_first_llm_provider_env(&["GENARRATIVE_LLM_PROVIDER", "LLM_PROVIDER"]) @@ -840,6 +848,26 @@ mod tests { } } + #[test] + fn from_env_reads_spacetime_procedure_timeout() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + + unsafe { + std::env::remove_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS"); + std::env::set_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS", "45"); + } + + let config = AppConfig::from_env(); + assert_eq!(config.spacetime_procedure_timeout.as_secs(), 45); + + unsafe { + std::env::remove_var("GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS"); + } + } + #[test] fn from_env_reads_rpg_llm_web_search_switch() { let _guard = ENV_LOCK diff --git a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs index 9aee3da5..7579d214 100644 --- a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs +++ b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs @@ -1,6 +1,8 @@ use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest}; use serde_json::Value as JsonValue; +use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; + #[derive(Clone, Copy, Debug)] pub(crate) struct CreationAgentLlmTurnErrorMessages<'a> { pub model_unavailable: &'a str, @@ -69,6 +71,8 @@ fn build_creation_agent_llm_request( LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api() .with_web_search(enable_web_search) } @@ -79,10 +83,14 @@ pub(crate) async fn request_creation_agent_json_turn( build_error: impl Fn(String) -> E, ) -> Result { let response = llm_client - .request_text(LlmTextRequest::new(vec![ - LlmMessage::system(system_prompt), - LlmMessage::user(user_prompt), - ])) + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api(), + ) .await .map_err(|error| build_error(error.to_string()))?; parse_json_response_text(response.content.as_str()) @@ -160,6 +168,8 @@ fn read_reply_text(parsed: &JsonValue) -> Option { #[cfg(test)] mod tests { + use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; + use super::{ build_creation_agent_llm_request, extract_reply_text_from_partial_json, parse_json_response_text, @@ -188,6 +198,8 @@ mod tests { build_creation_agent_llm_request("系统提示".to_string(), "用户提示".to_string(), true); assert!(request.enable_web_search); + assert_eq!(request.model.as_deref(), Some(CREATION_TEMPLATE_LLM_MODEL)); + assert_eq!(request.protocol, platform_llm::LlmTextProtocol::Responses); assert_eq!(request.messages.len(), 2); } } diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 43fe3f10..704a7f0c 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -38,9 +38,10 @@ use spacetime_client::{ CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, - CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, - CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, - CustomWorldWorkSummaryRecord, SpacetimeClientError, + CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput, + CustomWorldProfileRemixRecordInput, CustomWorldProfileUpsertRecordInput, + CustomWorldPublishGateRecord, CustomWorldResultPreviewBlockerRecord, + CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, SpacetimeClientError, }; use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -72,6 +73,7 @@ use crate::{ }, request_context::RequestContext, state::AppState, + work_author::resolve_work_author_by_user_id, }; const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3; @@ -415,7 +417,7 @@ pub async fn get_custom_world_library( let owner_user_id = authenticated.claims().user_id().to_string(); let entries = state .spacetime_client() - .list_custom_world_profiles(owner_user_id) + .list_custom_world_works(owner_user_id.clone()) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) @@ -426,7 +428,13 @@ pub async fn get_custom_world_library( CustomWorldLibraryResponse { entries: entries .into_iter() - .map(map_custom_world_library_entry_response) + .filter_map(|item| { + map_custom_world_library_entry_response_from_work_summary( + &state, + item, + &owner_user_id, + ) + }) .collect(), }, )) @@ -459,7 +467,7 @@ pub async fn get_custom_world_library_detail( Ok(json_success_body( Some(&request_context), CustomWorldGalleryDetailResponse { - entry: map_custom_world_library_entry_response(detail.entry), + entry: map_custom_world_library_entry_response(&state, detail.entry), }, )) } @@ -540,8 +548,11 @@ pub async fn put_custom_world_library_profile( Ok(json_success_body( Some(&request_context), CustomWorldLibraryMutationResponse { - entry: map_custom_world_library_entry_response(mutation.entry.clone()), - entries: vec![map_custom_world_library_entry_response(mutation.entry)], + entry: map_custom_world_library_entry_response(&state, mutation.entry.clone()), + entries: vec![map_custom_world_library_entry_response( + &state, + mutation.entry, + )], }, )) } @@ -576,7 +587,7 @@ pub async fn delete_custom_world_library_profile( CustomWorldLibraryResponse { entries: entries .into_iter() - .map(map_custom_world_library_entry_response) + .map(|entry| map_custom_world_library_entry_response(&state, entry)) .collect(), }, )) @@ -628,8 +639,11 @@ pub async fn publish_custom_world_library_profile( Ok(json_success_body( Some(&request_context), CustomWorldLibraryMutationResponse { - entry: map_custom_world_library_entry_response(mutation.entry.clone()), - entries: vec![map_custom_world_library_entry_response(mutation.entry)], + entry: map_custom_world_library_entry_response(&state, mutation.entry.clone()), + entries: vec![map_custom_world_library_entry_response( + &state, + mutation.entry, + )], }, )) } @@ -667,8 +681,11 @@ pub async fn unpublish_custom_world_library_profile( Ok(json_success_body( Some(&request_context), CustomWorldLibraryMutationResponse { - entry: map_custom_world_library_entry_response(mutation.entry.clone()), - entries: vec![map_custom_world_library_entry_response(mutation.entry)], + entry: map_custom_world_library_entry_response(&state, mutation.entry.clone()), + entries: vec![map_custom_world_library_entry_response( + &state, + mutation.entry, + )], }, )) } @@ -690,7 +707,7 @@ pub async fn list_custom_world_gallery( CustomWorldGalleryResponse { entries: entries .into_iter() - .map(map_custom_world_gallery_card_response) + .map(|entry| map_custom_world_gallery_card_response(&state, entry)) .collect(), }, )) @@ -722,7 +739,7 @@ pub async fn get_custom_world_gallery_detail( Ok(json_success_body( Some(&request_context), CustomWorldGalleryDetailResponse { - entry: map_custom_world_library_entry_response(detail.entry), + entry: map_custom_world_library_entry_response(&state, detail.entry), }, )) } @@ -753,7 +770,123 @@ pub async fn get_custom_world_gallery_detail_by_code( Ok(json_success_body( Some(&request_context), CustomWorldGalleryDetailResponse { - entry: map_custom_world_library_entry_response(detail.entry), + entry: map_custom_world_library_entry_response(&state, detail.entry), + }, + )) +} + +pub async fn remix_custom_world_gallery_profile( + State(state): State, + Path((owner_user_id, profile_id)): Path<(String, String)>, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-gallery", + "message": "ownerUserId and profileId are required", + })), + )); + } + + let mutation = state + .spacetime_client() + .remix_custom_world_profile(CustomWorldProfileRemixRecordInput { + source_owner_user_id: owner_user_id, + source_profile_id: profile_id, + target_owner_user_id: authenticated.claims().user_id().to_string(), + target_profile_id: build_prefixed_uuid_id("custom-world-profile-"), + author_display_name: resolve_author_display_name(&state, &authenticated), + remixed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldLibraryMutationResponse { + entry: map_custom_world_library_entry_response(&state, mutation.entry.clone()), + entries: vec![map_custom_world_library_entry_response( + &state, + mutation.entry, + )], + }, + )) +} + +pub async fn record_custom_world_gallery_play( + State(state): State, + Path((owner_user_id, profile_id)): Path<(String, String)>, + Extension(request_context): Extension, + Extension(_authenticated): Extension, +) -> Result, Response> { + if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-gallery", + "message": "ownerUserId and profileId are required", + })), + )); + } + + let mutation = state + .spacetime_client() + .record_custom_world_profile_play(CustomWorldProfilePlayReportRecordInput { + owner_user_id, + profile_id, + played_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldGalleryDetailResponse { + entry: map_custom_world_library_entry_response(&state, mutation.entry), + }, + )) +} + +pub async fn record_custom_world_gallery_like( + State(state): State, + Path((owner_user_id, profile_id)): Path<(String, String)>, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-gallery", + "message": "ownerUserId and profileId are required", + })), + )); + } + + let mutation = state + .spacetime_client() + .record_custom_world_profile_like(CustomWorldProfileLikeReportRecordInput { + owner_user_id, + profile_id, + user_id: authenticated.claims().user_id().to_string(), + liked_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldGalleryDetailResponse { + entry: map_custom_world_library_entry_response(&state, mutation.entry), }, )) } @@ -2613,18 +2746,25 @@ async fn upsert_custom_world_draft_foundation_progress( } fn map_custom_world_library_entry_response( + state: &AppState, entry: CustomWorldLibraryEntryRecord, ) -> CustomWorldLibraryEntryResponse { + let author = resolve_work_author_by_user_id( + state, + &entry.owner_user_id, + Some(&entry.author_display_name), + entry.author_public_user_code.as_deref(), + ); CustomWorldLibraryEntryResponse { owner_user_id: entry.owner_user_id, profile_id: entry.profile_id, public_work_code: entry.public_work_code, - author_public_user_code: entry.author_public_user_code, + author_public_user_code: author.public_user_code.or(entry.author_public_user_code), profile: entry.profile, visibility: entry.visibility, published_at: entry.published_at, updated_at: entry.updated_at, - author_display_name: entry.author_display_name, + author_display_name: author.display_name, world_name: entry.world_name, subtitle: entry.subtitle, summary_text: entry.summary_text, @@ -2632,21 +2772,114 @@ fn map_custom_world_library_entry_response( theme_mode: entry.theme_mode, playable_npc_count: entry.playable_npc_count, landmark_count: entry.landmark_count, + play_count: entry.play_count, + remix_count: entry.remix_count, + like_count: entry.like_count, + recent_play_count_7d: 0, } } +fn map_custom_world_library_entry_response_from_work_summary( + state: &AppState, + item: CustomWorldWorkSummaryRecord, + owner_user_id: &str, +) -> Option { + let profile_id = item.profile_id.as_ref()?.clone(); + let profile = build_custom_world_library_list_profile_payload(&item, &profile_id); + let author = resolve_work_author_by_user_id(state, owner_user_id, None, None); + Some(CustomWorldLibraryEntryResponse { + owner_user_id: owner_user_id.to_string(), + public_work_code: (item.status == "published") + .then(|| build_public_work_code_from_profile_id(&profile_id)), + profile_id, + author_public_user_code: author.public_user_code, + profile, + visibility: item.status, + published_at: item.published_at, + updated_at: item.updated_at, + author_display_name: author.display_name, + world_name: item.title, + subtitle: item.subtitle, + summary_text: item.summary, + cover_image_src: item.cover_image_src, + theme_mode: "mythic".to_string(), + playable_npc_count: item.playable_npc_count, + landmark_count: item.landmark_count, + play_count: 0, + remix_count: 0, + like_count: 0, + recent_play_count_7d: 0, + }) +} + +fn build_public_work_code_from_profile_id(profile_id: &str) -> String { + let digits = profile_id + .chars() + .filter(|character| character.is_ascii_digit()) + .collect::(); + let normalized_digits = if digits.is_empty() { + let checksum = profile_id.bytes().fold(0u32, |accumulator, value| { + accumulator.wrapping_mul(131) + u32::from(value) + }); + format!("{:08}", checksum % 100_000_000) + } else { + format!("{:0>8}", &digits[digits.len().saturating_sub(8)..]) + }; + + format!("CW-{normalized_digits}") +} + +fn build_custom_world_library_list_profile_payload( + item: &CustomWorldWorkSummaryRecord, + profile_id: &str, +) -> Value { + json!({ + "id": profile_id, + "name": item.title, + "subtitle": item.subtitle, + "summary": item.summary, + "tone": "", + "playerGoal": "", + "settingText": "", + "themeMode": "mythic", + "templateWorldType": "WUXIA", + "compatibilityTemplateWorldType": Value::Null, + "cover": item.cover_image_src.as_ref().map(|image_src| json!({ + "sourceType": "generated", + "imageSrc": image_src, + })), + "majorFactions": [], + "coreConflicts": [], + "playableNpcs": [], + "storyNpcs": [], + "items": [], + "camp": Value::Null, + "landmarks": [], + "ownedSettingLayers": Value::Null, + }) +} + fn map_custom_world_gallery_card_response( + state: &AppState, entry: CustomWorldGalleryEntryRecord, ) -> CustomWorldGalleryCardResponse { + let author = resolve_work_author_by_user_id( + state, + &entry.owner_user_id, + Some(&entry.author_display_name), + Some(&entry.author_public_user_code), + ); CustomWorldGalleryCardResponse { owner_user_id: entry.owner_user_id, profile_id: entry.profile_id, public_work_code: entry.public_work_code, - author_public_user_code: entry.author_public_user_code, + author_public_user_code: author + .public_user_code + .unwrap_or(entry.author_public_user_code), visibility: entry.visibility, published_at: entry.published_at, updated_at: entry.updated_at, - author_display_name: entry.author_display_name, + author_display_name: author.display_name, world_name: entry.world_name, subtitle: entry.subtitle, summary_text: entry.summary_text, @@ -2654,6 +2887,10 @@ fn map_custom_world_gallery_card_response( theme_mode: entry.theme_mode, playable_npc_count: entry.playable_npc_count, landmark_count: entry.landmark_count, + play_count: entry.play_count, + remix_count: entry.remix_count, + like_count: entry.like_count, + recent_play_count_7d: entry.recent_play_count_7d, } } @@ -3225,7 +3462,7 @@ fn resolve_author_public_user_code( request_context, AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "custom-world-library", - "message": format!("作者叙世号读取失败:{error}"), + "message": format!("作者百梦号读取失败:{error}"), })), ) })? @@ -3236,7 +3473,7 @@ fn resolve_author_public_user_code( request_context, AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({ "provider": "custom-world-library", - "message": "当前登录用户缺少叙世号", + "message": "当前登录用户缺少百梦号", })), ) }) diff --git a/server-rs/crates/api-server/src/custom_world_agent_entities.rs b/server-rs/crates/api-server/src/custom_world_agent_entities.rs index be247b2a..1e36853c 100644 --- a/server-rs/crates/api-server/src/custom_world_agent_entities.rs +++ b/server-rs/crates/api-server/src/custom_world_agent_entities.rs @@ -1,6 +1,8 @@ use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; use serde_json::{Map as JsonMap, Value as JsonValue}; use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest; + +use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; use spacetime_client::CustomWorldAgentSessionRecord; const CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT: &str = @@ -92,10 +94,15 @@ pub async fn generate_custom_world_agent_entities( }; let response = llm_client - .request_text(LlmTextRequest::new(vec![ - LlmMessage::system(system_prompt), - LlmMessage::user(user_prompt), - ])) + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api() + .with_web_search(true), + ) .await .map_err(|error| format!("{action} LLM 请求失败:{error}"))?; let generated_entities = parse_json_array_response(response.content.as_str()) diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index c1efe352..b6b10402 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -35,6 +35,7 @@ use crate::{ build_result_scene_npc_system_prompt, build_result_scene_npc_user_prompt, }, http_error::AppError, + llm_model_routing::CREATION_TEMPLATE_LLM_MODEL, platform_errors::map_oss_error, prompt::scene_background::{ DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, SceneImagePromptLandmark, @@ -1032,7 +1033,10 @@ async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: let request = LlmTextRequest::new(vec![ LlmMessage::system(build_result_entity_system_prompt()), LlmMessage::user(build_result_entity_user_prompt(profile, kind, &fallback)), - ]); + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api() + .with_web_search(true); llm_client .request_text(request) @@ -1058,7 +1062,10 @@ async fn generate_scene_npc_with_fallback( landmark_id, &fallback, )), - ]); + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api() + .with_web_search(true); llm_client .request_text(request) diff --git a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs index d52a7748..df2aab05 100644 --- a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs +++ b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs @@ -11,6 +11,8 @@ use serde_json::{Map as JsonMap, Value as JsonValue, json}; use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest; use spacetime_client::CustomWorldAgentSessionRecord; +use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CustomWorldFoundationDraftResult { pub draft_profile_json: String, @@ -39,7 +41,7 @@ pub async fn generate_custom_world_foundation_draft( emit_foundation_draft_progress( &mut on_progress, "整理世界骨架", - "正在根据创作者锚点生成第一版世界框架。", + "正在根据百梦主锚点生成第一版世界框架。", 12, ); let mut framework = request_foundation_json_stage( @@ -174,10 +176,15 @@ where F: Fn(&str) -> String, { let response = llm_client - .request_text(LlmTextRequest::new(vec![ - LlmMessage::system(FOUNDATION_JSON_ONLY_SYSTEM_PROMPT), - LlmMessage::user(user_prompt), - ])) + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(FOUNDATION_JSON_ONLY_SYSTEM_PROMPT), + LlmMessage::user(user_prompt), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api() + .with_web_search(true), + ) .await .map_err(|error| format!("{debug_label} LLM 请求失败:{error}"))?; let text = response.content.trim(); @@ -188,10 +195,14 @@ where Ok(value) => Ok(value), Err(_) => { let repaired = llm_client - .request_text(LlmTextRequest::new(vec![ - LlmMessage::system(FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT), - LlmMessage::user(repair_prompt_builder(text)), - ])) + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT), + LlmMessage::user(repair_prompt_builder(text)), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api(), + ) .await .map_err(|error| format!("{repair_debug_label} LLM 请求失败:{error}"))?; parse_json_response_text(repaired.content.as_str()) diff --git a/server-rs/crates/api-server/src/llm.rs b/server-rs/crates/api-server/src/llm.rs index 646be46f..6d1c8fbc 100644 --- a/server-rs/crates/api-server/src/llm.rs +++ b/server-rs/crates/api-server/src/llm.rs @@ -7,7 +7,7 @@ use axum::{ sse::{Event, Sse}, }, }; -use platform_llm::{LlmMessage, LlmMessageRole, LlmTextRequest}; +use platform_llm::{LlmError, LlmMessage, LlmMessageRole, LlmTextProtocol, LlmTextRequest}; use serde_json::{Value, json}; use shared_contracts::llm::{ LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole, @@ -35,6 +35,7 @@ pub async fn proxy_llm_chat_completions( let request = LlmTextRequest { model: payload.model, + protocol: LlmTextProtocol::ChatCompletions, messages: payload .messages .into_iter() diff --git a/server-rs/crates/api-server/src/llm_model_routing.rs b/server-rs/crates/api-server/src/llm_model_routing.rs new file mode 100644 index 00000000..78d5cdda --- /dev/null +++ b/server-rs/crates/api-server/src/llm_model_routing.rs @@ -0,0 +1,2 @@ +pub(crate) const RPG_STORY_LLM_MODEL: &str = "doubao-seed-character-251128"; +pub(crate) const CREATION_TEMPLATE_LLM_MODEL: &str = "deepseek-v3-2-251201"; diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 248ff5be..7ea6db43 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -35,17 +35,21 @@ mod error_middleware; mod health; mod http_error; mod llm; +mod llm_model_routing; mod login_options; mod logout; mod logout_all; +mod match3d; mod password_entry; mod password_management; mod phone_auth; mod platform_errors; +mod profile_identity; mod prompt; mod puzzle; mod puzzle_agent_turn; mod refresh_session; +mod registration_reward; mod request_context; mod response_headers; mod runtime_browse_history; @@ -61,6 +65,7 @@ mod story_battles; mod story_sessions; mod wechat_auth; mod wechat_provider; +mod work_author; use shared_logging::init_tracing; use tokio::net::TcpListener; diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs new file mode 100644 index 00000000..65606337 --- /dev/null +++ b/server-rs/crates/api-server/src/match3d.rs @@ -0,0 +1,1578 @@ +use std::{ + convert::Infallible, + time::{SystemTime, UNIX_EPOCH}, +}; + +use axum::{ + Json, + extract::{Extension, Path, State, rejection::JsonRejection}, + http::{HeaderName, StatusCode, header}, + response::{ + IntoResponse, Response, + sse::{Event, Sse}, + }, +}; +use module_match3d::{ + MATCH3D_MESSAGE_ID_PREFIX, MATCH3D_PROFILE_ID_PREFIX, MATCH3D_RUN_ID_PREFIX, + MATCH3D_SESSION_ID_PREFIX, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use shared_contracts::{ + match3d_agent::{ + CreateMatch3DAgentSessionRequest, ExecuteMatch3DAgentActionRequest, + Match3DAgentActionResponse, Match3DAgentMessageResponse, Match3DAgentSessionResponse, + Match3DAgentSessionSnapshotResponse, Match3DAnchorItemResponse, Match3DAnchorPackResponse, + Match3DCreatorConfigResponse, Match3DResultDraftResponse, SendMatch3DAgentMessageRequest, + }, + match3d_runtime::{ + ClickMatch3DItemRequest, Match3DClickConfirmationResponse, Match3DClickResponse, + Match3DItemSnapshotResponse, Match3DRunResponse, Match3DRunSnapshotResponse, + Match3DTraySlotResponse, StartMatch3DRunRequest, StopMatch3DRunRequest, + }, + match3d_works::{ + Match3DWorkDetailResponse, Match3DWorkMutationResponse, Match3DWorkProfileResponse, + Match3DWorkSummaryResponse, Match3DWorksResponse, PutMatch3DWorkRequest, + }, +}; +use shared_kernel::build_prefixed_uuid_id; +use spacetime_client::{ + Match3DAgentMessageFinalizeRecordInput, Match3DAgentMessageRecord, + Match3DAgentMessageSubmitRecordInput, Match3DAgentSessionCreateRecordInput, + Match3DAgentSessionRecord, Match3DAnchorItemRecord, Match3DAnchorPackRecord, + Match3DClickConfirmationRecord, Match3DCompileDraftRecordInput, Match3DCreatorConfigRecord, + Match3DItemSnapshotRecord, Match3DResultDraftRecord, Match3DRunClickRecordInput, + Match3DRunRecord, Match3DRunRestartRecordInput, Match3DRunStartRecordInput, + Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput, Match3DTraySlotRecord, + Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput, SpacetimeClientError, +}; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent"; +const MATCH3D_WORKS_PROVIDER: &str = "match3d-works"; +const MATCH3D_RUNTIME_PROVIDER: &str = "match3d-runtime"; +const MATCH3D_DEFAULT_THEME: &str = "缤纷玩具"; +const MATCH3D_DEFAULT_CLEAR_COUNT: u32 = 12; +const MATCH3D_DEFAULT_DIFFICULTY: u32 = 4; +const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材"; +const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关"; +const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10,你要创作的关卡是难度几"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Match3DConfigJson { + theme_text: String, + reference_image_src: Option, + clear_count: u32, + difficulty: u32, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CompileMatch3DDraftRequest { + #[serde(default)] + game_name: Option, + #[serde(default)] + summary: Option, + #[serde(default)] + tags: Option>, + #[serde(default)] + cover_image_src: Option, +} + +pub async fn create_match3d_agent_session( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; + let config = build_config_from_create_request(&payload); + let seed_text = build_seed_text(&payload, &config); + let welcome_message_text = MATCH3D_QUESTION_THEME.to_string(); + + let session = state + .spacetime_client() + .create_match3d_agent_session(Match3DAgentSessionCreateRecordInput { + session_id: build_prefixed_uuid_id(MATCH3D_SESSION_ID_PREFIX), + owner_user_id: authenticated.claims().user_id().to_string(), + seed_text, + welcome_message_id: build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX), + welcome_message_text, + config_json: serialize_match3d_config(&config), + created_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentSessionResponse { + session: map_match3d_agent_session_response(session), + }, + )) +} + +pub async fn get_match3d_agent_session( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + + let session = state + .spacetime_client() + .get_match3d_agent_session(session_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentSessionResponse { + session: map_match3d_agent_session_response(session), + }, + )) +} + +pub async fn submit_match3d_agent_message( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; + let session = submit_and_finalize_match3d_message( + &state, + &request_context, + authenticated.claims().user_id(), + session_id, + payload, + ) + .await?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentSessionResponse { + session: map_match3d_agent_session_response(session), + }, + )) +} + +pub async fn stream_match3d_agent_message( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let request_context_for_stream = request_context.clone(); + let stream = async_stream::stream! { + let result = submit_and_finalize_match3d_message( + &state, + &request_context_for_stream, + owner_user_id.as_str(), + session_id, + payload, + ) + .await; + + match result { + Ok(session) => { + let session_response = map_match3d_agent_session_response(session); + if let Some(reply) = session_response.last_assistant_reply.clone() { + yield Ok::(match3d_sse_json_event_or_error( + "reply_delta", + json!({ "text": reply }), + )); + } + yield Ok::(match3d_sse_json_event_or_error( + "session", + json!({ "session": session_response }), + )); + yield Ok::(match3d_sse_json_event_or_error( + "done", + json!({ "ok": true }), + )); + } + Err(response) => { + yield Ok::(match3d_sse_json_event_or_error( + "error", + json!({ "message": response.status().to_string() }), + )); + } + } + }; + + Ok(Sse::new(stream).into_response()) +} + +pub async fn execute_match3d_agent_action( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + + if payload.action.trim() != "match3d_compile_draft" { + return Err(match3d_bad_request( + &request_context, + MATCH3D_AGENT_PROVIDER, + "unknown match3d action", + )); + } + + let session = compile_match3d_draft_for_session( + &state, + &request_context, + &authenticated, + session_id, + payload.game_name, + payload.summary, + payload.tags, + payload.cover_image_src, + ) + .await?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentActionResponse { + session: map_match3d_agent_session_response(session), + }, + )) +} + +pub async fn compile_match3d_agent_draft( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let payload = payload + .map(|Json(payload)| payload) + .unwrap_or(CompileMatch3DDraftRequest { + game_name: None, + summary: None, + tags: None, + cover_image_src: None, + }); + ensure_non_empty( + &request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + + let session = compile_match3d_draft_for_session( + &state, + &request_context, + &authenticated, + session_id, + payload.game_name, + payload.summary, + payload.tags, + payload.cover_image_src, + ) + .await?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentActionResponse { + session: map_match3d_agent_session_response(session), + }, + )) +} + +pub async fn get_match3d_works( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_match3d_works(authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorksResponse { + items: items + .into_iter() + .map(map_match3d_work_summary_response) + .collect(), + }, + )) +} + +pub async fn list_match3d_gallery( + State(state): State, + Extension(request_context): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_match3d_gallery() + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorksResponse { + items: items + .into_iter() + .map(map_match3d_work_summary_response) + .collect(), + }, + )) +} + +pub async fn get_match3d_work_detail( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .get_match3d_work_detail(profile_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorkDetailResponse { + item: map_match3d_work_profile_response(item), + }, + )) +} + +pub async fn put_match3d_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let existing = state + .spacetime_client() + .get_match3d_work_detail( + profile_id.clone(), + authenticated.claims().user_id().to_string(), + ) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let theme_text = payload + .theme_text + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(existing.theme_text); + let item = state + .spacetime_client() + .update_match3d_work(Match3DWorkUpdateRecordInput { + profile_id, + owner_user_id: authenticated.claims().user_id().to_string(), + game_name: payload.game_name, + theme_text, + summary_text: payload.summary, + tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(), + cover_image_src: payload.cover_image_src.unwrap_or_default(), + cover_asset_id: String::new(), + clear_count: payload.clear_count, + difficulty: payload.difficulty, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorkMutationResponse { + item: map_match3d_work_profile_response(item), + }, + )) +} + +pub async fn publish_match3d_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .publish_match3d_work( + profile_id, + authenticated.claims().user_id().to_string(), + current_utc_micros(), + ) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorkMutationResponse { + item: map_match3d_work_profile_response(item), + }, + )) +} + +pub async fn delete_match3d_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let items = state + .spacetime_client() + .delete_match3d_work(profile_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorksResponse { + items: items + .into_iter() + .map(map_match3d_work_summary_response) + .collect(), + }, + )) +} + +pub async fn start_match3d_run( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let maybe_payload = payload.ok().map(|Json(payload)| payload); + let profile_id = maybe_payload + .map(|payload| payload.profile_id) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(profile_id); + ensure_non_empty( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + &profile_id, + "profileId", + )?; + + let run = state + .spacetime_client() + .start_match3d_run(Match3DRunStartRecordInput { + run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), + owner_user_id: authenticated.claims().user_id().to_string(), + profile_id, + started_at_ms: current_utc_ms(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} + +pub async fn get_match3d_run( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .get_match3d_run(run_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} + +pub async fn click_match3d_item( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_RUNTIME_PROVIDER)?; + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + ensure_non_empty( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + &payload.item_instance_id, + "itemInstanceId", + )?; + ensure_non_empty( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + &payload.client_event_id, + "clientEventId", + )?; + + let confirmation = state + .spacetime_client() + .click_match3d_item(Match3DRunClickRecordInput { + run_id: payload.run_id.unwrap_or(run_id), + owner_user_id: authenticated.claims().user_id().to_string(), + item_instance_id: payload.item_instance_id, + client_snapshot_version: payload.client_snapshot_version.min(u32::MAX as u64) as u32, + client_event_id: payload.client_event_id, + clicked_at_ms: payload.clicked_at_ms.min(i64::MAX as u64) as i64, + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DClickResponse { + confirmation: map_match3d_click_confirmation_response(confirmation), + }, + )) +} + +pub async fn stop_match3d_run( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let _ = payload.ok(); + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .stop_match3d_run(Match3DRunStopRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + stopped_at_ms: current_utc_ms(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} + +pub async fn restart_match3d_run( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .restart_match3d_run(Match3DRunRestartRecordInput { + source_run_id: run_id, + next_run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), + owner_user_id: authenticated.claims().user_id().to_string(), + restarted_at_ms: current_utc_ms(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} + +pub async fn finish_match3d_time_up( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .finish_match3d_time_up(Match3DRunTimeUpRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + finished_at_ms: current_utc_ms(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} + +async fn submit_and_finalize_match3d_message( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + session_id: String, + payload: SendMatch3DAgentMessageRequest, +) -> Result { + ensure_non_empty( + request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + ensure_non_empty( + request_context, + MATCH3D_AGENT_PROVIDER, + &payload.client_message_id, + "clientMessageId", + )?; + ensure_non_empty( + request_context, + MATCH3D_AGENT_PROVIDER, + &payload.text, + "text", + )?; + + let submitted = state + .spacetime_client() + .submit_match3d_agent_message(Match3DAgentMessageSubmitRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.to_string(), + user_message_id: payload.client_message_id.clone(), + user_message_text: payload.text.clone(), + submitted_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let next_turn = submitted.current_turn.saturating_add(1); + let next_config = build_config_from_message(&submitted, &payload); + let assistant_reply = build_match3d_assistant_reply_for_turn(&next_config, next_turn); + let progress_percent = resolve_progress_percent_for_turn(next_turn); + let stage = if progress_percent >= 100 { + "ReadyToCompile" + } else { + "Collecting" + } + .to_string(); + + state + .spacetime_client() + .finalize_match3d_agent_message(Match3DAgentMessageFinalizeRecordInput { + session_id, + owner_user_id: owner_user_id.to_string(), + assistant_message_id: Some(build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX)), + assistant_reply_text: Some(assistant_reply), + config_json: serialize_match3d_config(&next_config), + progress_percent, + stage, + updated_at_micros: current_utc_micros(), + error_message: None, + }) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + }) +} + +async fn compile_match3d_draft_for_session( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + session_id: String, + game_name: Option, + summary: Option, + tags: Option>, + cover_image_src: Option, +) -> Result { + let owner_user_id = authenticated.claims().user_id().to_string(); + let session = state + .spacetime_client() + .get_match3d_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + })?; + if session.current_turn < 3 || session.progress_percent < 100 { + return Err(match3d_bad_request( + request_context, + MATCH3D_AGENT_PROVIDER, + "match3d 创作配置尚未确认完成", + )); + } + + let config = resolve_config_or_default(session.config.as_ref()); + let tags_json = tags + .as_ref() + .map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default()); + + state + .spacetime_client() + .compile_match3d_draft(Match3DCompileDraftRecordInput { + session_id, + owner_user_id, + profile_id: build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX), + author_display_name: resolve_author_display_name(state, authenticated), + game_name: game_name.or_else(|| Some(format!("{}抓大鹅", config.theme_text))), + summary_text: summary, + tags_json, + cover_image_src, + cover_asset_id: None, + compiled_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + }) +} + +fn map_match3d_agent_session_response( + session: Match3DAgentSessionRecord, +) -> Match3DAgentSessionSnapshotResponse { + Match3DAgentSessionSnapshotResponse { + session_id: session.session_id, + current_turn: session.current_turn, + progress_percent: session.progress_percent, + stage: session.stage.clone(), + anchor_pack: map_match3d_anchor_pack_response_for_turn( + session.anchor_pack, + session.current_turn, + session.stage.as_str(), + ), + config: session.config.map(map_match3d_config_response), + draft: session.draft.map(map_match3d_draft_response), + messages: session + .messages + .into_iter() + .map(map_match3d_message_response) + .collect(), + last_assistant_reply: session.last_assistant_reply, + published_profile_id: session.published_profile_id, + updated_at: session.updated_at, + } +} + +fn map_match3d_anchor_pack_response_for_turn( + anchor: Match3DAnchorPackRecord, + current_turn: u32, + stage: &str, +) -> Match3DAnchorPackResponse { + let is_ready = matches!( + stage, + "ReadyToCompile" + | "ready_to_compile" + | "DraftCompiled" + | "draft_compiled" + | "draft_ready" + | "ReadyToPublish" + | "ready_to_publish" + | "Published" + | "published" + ); + let collected_count = if is_ready { 3 } else { current_turn.min(3) }; + + Match3DAnchorPackResponse { + theme: map_match3d_anchor_item_response_for_collected(anchor.theme, collected_count >= 1), + clear_count: map_match3d_anchor_item_response_for_collected( + anchor.clear_count, + collected_count >= 2, + ), + difficulty: map_match3d_anchor_item_response_for_collected( + anchor.difficulty, + collected_count >= 3, + ), + } +} + +fn map_match3d_anchor_item_response(anchor: Match3DAnchorItemRecord) -> Match3DAnchorItemResponse { + Match3DAnchorItemResponse { + key: anchor.key, + label: anchor.label, + value: anchor.value, + status: anchor.status, + } +} + +fn map_match3d_anchor_item_response_for_collected( + anchor: Match3DAnchorItemRecord, + collected: bool, +) -> Match3DAnchorItemResponse { + if collected { + return map_match3d_anchor_item_response(anchor); + } + + Match3DAnchorItemResponse { + key: anchor.key, + label: anchor.label, + value: String::new(), + status: "missing".to_string(), + } +} + +fn map_match3d_config_response(config: Match3DCreatorConfigRecord) -> Match3DCreatorConfigResponse { + Match3DCreatorConfigResponse { + theme_text: config.theme_text, + reference_image_src: config.reference_image_src, + clear_count: config.clear_count, + difficulty: config.difficulty, + } +} + +fn map_match3d_draft_response(draft: Match3DResultDraftRecord) -> Match3DResultDraftResponse { + Match3DResultDraftResponse { + profile_id: draft.profile_id, + game_name: draft.game_name, + theme_text: draft.theme_text, + summary_text: Some(draft.summary_text.clone()), + summary: draft.summary_text, + tags: draft.tags, + cover_image_src: draft.cover_image_src, + reference_image_src: draft.reference_image_src, + clear_count: draft.clear_count, + difficulty: draft.difficulty, + total_item_count: draft.total_item_count, + publish_ready: draft.publish_ready, + blockers: draft.blockers, + } +} + +fn map_match3d_message_response(message: Match3DAgentMessageRecord) -> Match3DAgentMessageResponse { + Match3DAgentMessageResponse { + id: message.message_id, + role: message.role, + kind: message.kind, + text: message.text, + created_at: message.created_at, + } +} + +fn map_match3d_work_summary_response(item: Match3DWorkProfileRecord) -> Match3DWorkSummaryResponse { + Match3DWorkSummaryResponse { + work_id: item.work_id, + profile_id: item.profile_id, + owner_user_id: item.owner_user_id, + source_session_id: item.source_session_id, + game_name: item.game_name, + theme_text: item.theme_text, + summary: item.summary, + tags: item.tags, + cover_image_src: item.cover_image_src, + reference_image_src: item.reference_image_src, + clear_count: item.clear_count, + difficulty: item.difficulty, + publication_status: item.publication_status, + play_count: item.play_count, + updated_at: item.updated_at, + published_at: item.published_at, + publish_ready: item.publish_ready, + } +} + +fn map_match3d_work_profile_response(item: Match3DWorkProfileRecord) -> Match3DWorkProfileResponse { + Match3DWorkProfileResponse { + summary: map_match3d_work_summary_response(item), + } +} + +fn map_match3d_run_response(run: Match3DRunRecord) -> Match3DRunSnapshotResponse { + Match3DRunSnapshotResponse { + run_id: run.run_id, + profile_id: run.profile_id, + owner_user_id: run.owner_user_id, + status: normalize_match3d_run_status(run.status.as_str()).to_string(), + snapshot_version: run.snapshot_version, + started_at_ms: run.started_at_ms, + duration_limit_ms: run.duration_limit_ms, + server_now_ms: run.server_now_ms, + remaining_ms: run.remaining_ms, + clear_count: run.clear_count, + total_item_count: run.total_item_count, + cleared_item_count: run.cleared_item_count, + items: run + .items + .into_iter() + .map(map_match3d_item_response) + .collect(), + tray_slots: run + .tray_slots + .into_iter() + .map(map_match3d_tray_slot_response) + .collect(), + failure_reason: run + .failure_reason + .map(|reason| normalize_match3d_failure_reason(reason.as_str()).to_string()), + last_confirmed_action_id: run.last_confirmed_action_id, + } +} + +fn map_match3d_item_response(item: Match3DItemSnapshotRecord) -> Match3DItemSnapshotResponse { + Match3DItemSnapshotResponse { + item_instance_id: item.item_instance_id, + item_type_id: item.item_type_id, + visual_key: item.visual_key, + x: item.x, + y: item.y, + radius: item.radius, + layer: item.layer, + state: normalize_match3d_item_state(item.state.as_str()).to_string(), + clickable: item.clickable, + tray_slot_index: item.tray_slot_index, + } +} + +fn map_match3d_tray_slot_response(slot: Match3DTraySlotRecord) -> Match3DTraySlotResponse { + Match3DTraySlotResponse { + slot_index: slot.slot_index, + item_instance_id: slot.item_instance_id, + item_type_id: slot.item_type_id, + visual_key: slot.visual_key, + } +} + +fn map_match3d_click_confirmation_response( + confirmation: Match3DClickConfirmationRecord, +) -> Match3DClickConfirmationResponse { + Match3DClickConfirmationResponse { + accepted: confirmation.accepted, + reject_reason: confirmation + .reject_reason + .map(|reason| normalize_match3d_click_reject_reason(reason.as_str()).to_string()), + entered_slot_index: confirmation.entered_slot_index, + cleared_item_instance_ids: confirmation.cleared_item_instance_ids, + run: map_match3d_run_response(confirmation.run), + } +} + +fn build_config_from_create_request( + payload: &CreateMatch3DAgentSessionRequest, +) -> Match3DConfigJson { + Match3DConfigJson { + theme_text: payload + .theme_text + .as_deref() + .or(payload.seed_text.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(MATCH3D_DEFAULT_THEME) + .to_string(), + reference_image_src: payload.reference_image_src.clone(), + clear_count: payload + .clear_count + .unwrap_or(MATCH3D_DEFAULT_CLEAR_COUNT) + .max(1), + difficulty: payload + .difficulty + .unwrap_or(MATCH3D_DEFAULT_DIFFICULTY) + .clamp(1, 10), + } +} + +fn build_config_from_message( + session: &Match3DAgentSessionRecord, + payload: &SendMatch3DAgentMessageRequest, +) -> Match3DConfigJson { + let current = resolve_config_or_default(session.config.as_ref()); + let text = payload.text.trim(); + let reference_image_src = payload + .reference_image_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or(current.reference_image_src); + let quick_fill_requested = + payload.quick_fill_requested.unwrap_or(false) || text.contains("自动配置"); + + let mut theme_text = current.theme_text; + let mut clear_count = current.clear_count.max(1); + let mut difficulty = current.difficulty.clamp(1, 10); + + match session.current_turn { + 0 => { + theme_text = if quick_fill_requested { + MATCH3D_DEFAULT_THEME.to_string() + } else { + parse_theme_answer(text).unwrap_or(theme_text) + }; + } + 1 => { + clear_count = if quick_fill_requested { + clear_count + } else { + parse_number_after_keywords(text, &["消除", "次数", "clearCount"]) + .unwrap_or(clear_count) + } + .max(1); + } + _ => { + difficulty = if quick_fill_requested { + difficulty + } else { + parse_number_after_keywords(text, &["难度", "difficulty"]).unwrap_or(difficulty) + } + .clamp(1, 10); + } + } + + Match3DConfigJson { + theme_text, + reference_image_src, + clear_count, + difficulty, + } +} + +fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Match3DConfigJson { + config + .map(|config| Match3DConfigJson { + theme_text: config.theme_text.clone(), + reference_image_src: config.reference_image_src.clone(), + clear_count: config.clear_count.max(1), + difficulty: config.difficulty.clamp(1, 10), + }) + .unwrap_or_else(|| Match3DConfigJson { + theme_text: MATCH3D_DEFAULT_THEME.to_string(), + reference_image_src: None, + clear_count: MATCH3D_DEFAULT_CLEAR_COUNT, + difficulty: MATCH3D_DEFAULT_DIFFICULTY, + }) +} + +fn serialize_match3d_config(config: &Match3DConfigJson) -> Option { + serde_json::to_string(config).ok() +} + +fn build_seed_text( + payload: &CreateMatch3DAgentSessionRequest, + config: &Match3DConfigJson, +) -> String { + payload + .seed_text + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + format!( + "{}题材,消除{}次,难度{}", + config.theme_text, config.clear_count, config.difficulty + ) + }) +} + +fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String { + format!( + "已确认:{}题材,需要消除 {} 次,共 {} 件物品,难度 {}。", + config.theme_text, + config.clear_count, + config.clear_count.saturating_mul(3), + config.difficulty + ) +} + +fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String { + match current_turn { + 0 => MATCH3D_QUESTION_THEME.to_string(), + 1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(), + 2 => MATCH3D_QUESTION_DIFFICULTY.to_string(), + _ => build_match3d_assistant_reply(config), + } +} + +fn resolve_progress_percent_for_turn(current_turn: u32) -> u32 { + match current_turn { + 0 => 0, + 1 => 33, + 2 => 66, + _ => 100, + } +} + +fn parse_theme_answer(text: &str) -> Option { + for marker in ["题材", "主题"] { + if let Some((_, value)) = text.split_once(marker) { + let normalized = value + .trim_matches(|ch: char| ch == ':' || ch == ':' || ch.is_whitespace()) + .split_whitespace() + .next() + .unwrap_or_default() + .trim_matches(['。', ',', ',', ';', ';']) + .to_string(); + if !normalized.is_empty() { + return Some(normalized); + } + } + } + let trimmed = text.trim(); + if (2..=24).contains(&trimmed.chars().count()) && !trimmed.chars().any(|ch| ch.is_ascii_digit()) + { + return Some(trimmed.to_string()); + } + None +} + +fn parse_number_after_keywords(text: &str, keywords: &[&str]) -> Option { + for keyword in keywords { + if let Some(index) = text.find(keyword) { + let suffix = &text[index + keyword.len()..]; + if let Some(value) = first_positive_integer(suffix) { + return Some(value); + } + } + } + first_positive_integer(text) +} + +fn first_positive_integer(text: &str) -> Option { + let mut digits = String::new(); + for ch in text.chars() { + if ch.is_ascii_digit() { + digits.push(ch); + } else if !digits.is_empty() { + break; + } + } + digits.parse::().ok().filter(|value| *value > 0) +} + +fn normalize_tags(tags: Vec) -> Vec { + let mut result = Vec::new(); + for tag in tags { + let trimmed = tag.trim(); + if !trimmed.is_empty() && !result.iter().any(|value| value == trimmed) { + result.push(trimmed.to_string()); + } + if result.len() >= 6 { + break; + } + } + result +} + +fn resolve_author_display_name( + state: &AppState, + authenticated: &AuthenticatedAccessToken, +) -> String { + state + .auth_user_service() + .get_user_by_id(authenticated.claims().user_id()) + .ok() + .flatten() + .map(|user| user.display_name) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "玩家".to_string()) +} + +fn normalize_match3d_run_status(value: &str) -> &str { + match value { + "Running" => "running", + "Won" => "won", + "Failed" => "failed", + "Stopped" => "stopped", + _ => value, + } +} + +fn normalize_match3d_item_state(value: &str) -> &str { + match value { + "InBoard" => "in_board", + "InTray" => "in_tray", + "Cleared" => "cleared", + _ => value, + } +} + +fn normalize_match3d_failure_reason(value: &str) -> &str { + match value { + "TimeUp" => "time_up", + "TrayFull" => "tray_full", + _ => value, + } +} + +fn normalize_match3d_click_reject_reason(value: &str) -> &str { + match value { + "RejectedNotClickable" => "item_not_clickable", + "RejectedAlreadyMoved" => "item_not_in_board", + "RejectedTrayFull" => "tray_full", + "VersionConflict" => "snapshot_version_mismatch", + "RunFinished" => "run_not_active", + _ => value, + } +} + +fn ensure_non_empty( + request_context: &RequestContext, + provider: &str, + value: &str, + field_name: &str, +) -> Result<(), Response> { + if value.trim().is_empty() { + return Err(match3d_bad_request( + request_context, + provider, + format!("{field_name} is required").as_str(), + )); + } + Ok(()) +} + +fn match3d_json( + payload: Result, JsonRejection>, + request_context: &RequestContext, + provider: &str, +) -> Result, Response> { + payload.map_err(|error| { + match3d_error_response( + request_context, + provider, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": provider, + "message": error.body_text(), + })), + ) + }) +} + +fn match3d_bad_request( + request_context: &RequestContext, + provider: &str, + message: &str, +) -> Response { + match3d_error_response( + request_context, + provider, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": provider, + "message": message, + })), + ) +} + +fn map_match3d_client_error(error: SpacetimeClientError) -> AppError { + let status = match &error { + SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, + SpacetimeClientError::Procedure(message) + if message.contains("不存在") + || message.contains("not found") + || message.contains("does not exist") => + { + StatusCode::NOT_FOUND + } + SpacetimeClientError::Procedure(message) + if message.contains("发布需要") + || message.contains("不能为空") + || message.contains("必须") => + { + StatusCode::BAD_REQUEST + } + _ => StatusCode::BAD_GATEWAY, + }; + + AppError::from_status(status).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + +fn match3d_error_response( + request_context: &RequestContext, + provider: &str, + error: AppError, +) -> Response { + let mut response = error.into_response_with_context(Some(request_context)); + response.headers_mut().insert( + HeaderName::from_static("x-genarrative-provider"), + header::HeaderValue::from_str(provider) + .unwrap_or_else(|_| header::HeaderValue::from_static("match3d")), + ); + response +} + +fn match3d_sse_json_event(event_name: &str, payload: Value) -> Result { + Event::default() + .event(event_name) + .json_data(payload) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "sse", + "message": format!("SSE payload 序列化失败:{error}"), + })) + }) +} + +fn match3d_sse_json_event_or_error(event_name: &str, payload: Value) -> Event { + match match3d_sse_json_event(event_name, payload) { + Ok(event) => event, + Err(error) => Event::default().event("error").data(format!("{error:?}")), + } +} + +fn current_utc_micros() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + (duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros()) +} + +fn current_utc_ms() -> i64 { + current_utc_micros().saturating_div(1000) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(theme_text: &str, clear_count: u32, difficulty: u32) -> Match3DConfigJson { + Match3DConfigJson { + theme_text: theme_text.to_string(), + reference_image_src: None, + clear_count, + difficulty, + } + } + + #[test] + fn match3d_agent_reply_asks_three_questions_before_confirmation() { + let current = config("水果", 4, 6); + + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 0), + MATCH3D_QUESTION_THEME + ); + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 1), + MATCH3D_QUESTION_CLEAR_COUNT + ); + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 2), + MATCH3D_QUESTION_DIFFICULTY + ); + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 3), + "已确认:水果题材,需要消除 4 次,共 12 件物品,难度 6。" + ); + } + + #[test] + fn match3d_agent_progress_follows_question_turns() { + assert_eq!(resolve_progress_percent_for_turn(0), 0); + assert_eq!(resolve_progress_percent_for_turn(1), 33); + assert_eq!(resolve_progress_percent_for_turn(2), 66); + assert_eq!(resolve_progress_percent_for_turn(3), 100); + assert_eq!(resolve_progress_percent_for_turn(8), 100); + } + + #[test] + fn match3d_anchor_pack_masks_uncollected_default_values() { + let pack = Match3DAnchorPackRecord { + theme: Match3DAnchorItemRecord { + key: "theme".to_string(), + label: "题材主题".to_string(), + value: "缤纷玩具".to_string(), + status: "confirmed".to_string(), + }, + clear_count: Match3DAnchorItemRecord { + key: "clearCount".to_string(), + label: "需要消除次数".to_string(), + value: "12".to_string(), + status: "confirmed".to_string(), + }, + difficulty: Match3DAnchorItemRecord { + key: "difficulty".to_string(), + label: "难度".to_string(), + value: "4".to_string(), + status: "confirmed".to_string(), + }, + }; + + let response = map_match3d_anchor_pack_response_for_turn(pack, 0, "Collecting"); + + assert_eq!(response.theme.value, ""); + assert_eq!(response.theme.status, "missing"); + assert_eq!(response.clear_count.value, ""); + assert_eq!(response.clear_count.status, "missing"); + assert_eq!(response.difficulty.value, ""); + assert_eq!(response.difficulty.status, "missing"); + } +} diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index 52bbd3a3..308e3e87 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -39,6 +39,14 @@ pub async fn password_entry( state.password_entry_service().execute(input).await } .map_err(map_password_entry_error)?; + if result.created { + crate::registration_reward::grant_new_user_registration_wallet_reward( + &state, + &request_context, + &result.user.id, + ) + .await; + } let session_client = resolve_session_client_context(&headers); let signed_session = create_password_auth_session(&state, &result.user, &session_client)?; state @@ -80,10 +88,15 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError { "field": "password", })), PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST) - .with_message("叙世号格式不正确") + .with_message("百梦号格式不正确") .with_details(json!({ "field": "phone", })), + PasswordEntryError::InvalidDisplayName + | PasswordEntryError::InvalidAvatarDataUrl + | PasswordEntryError::EmptyProfileUpdate => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + } PasswordEntryError::InvalidCredentials => { AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误") } diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index cc75fbc2..f2895b20 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -103,6 +103,11 @@ fn map_password_management_error(error: PasswordEntryError) -> AppError { PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPublicUserCode => { AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) } + PasswordEntryError::InvalidDisplayName + | PasswordEntryError::InvalidAvatarDataUrl + | PasswordEntryError::EmptyProfileUpdate => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + } PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST) .with_message("密码长度需要在 6 到 128 位之间"), PasswordEntryError::InvalidCredentials => { diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index cf26e631..4e49bdac 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -9,7 +9,8 @@ use module_auth::{ }; use serde_json::json; use shared_contracts::auth::{ - PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, PhoneSendCodeResponse, + PhoneLoginReferralResponse, PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, + PhoneSendCodeResponse, }; use time::OffsetDateTime; use tracing::{info, warn}; @@ -111,6 +112,7 @@ pub async fn phone_login( AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用") ); } + let invite_code = payload.invite_code.clone(); let result = match state .phone_auth_service() .login( @@ -147,6 +149,26 @@ pub async fn phone_login( return Err(map_phone_auth_error(error)); } }; + let created = result.created; + if created { + crate::registration_reward::grant_new_user_registration_wallet_reward( + &state, + &request_context, + &result.user.id, + ) + .await; + } + let referral = if created { + bind_referral_invite_code_on_registration( + &state, + &request_context, + result.user.id.clone(), + invite_code, + ) + .await + } else { + None + }; let session_client = resolve_session_client_context(&headers); let signed_session = create_auth_session( &state, @@ -175,11 +197,55 @@ pub async fn phone_login( PhoneLoginResponse { token: signed_session.access_token, user: map_auth_user_payload(result.user), + created, + referral, }, ), )) } +async fn bind_referral_invite_code_on_registration( + state: &AppState, + request_context: &RequestContext, + user_id: String, + invite_code: Option, +) -> Option { + let invite_code = invite_code + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + match state + .spacetime_client() + .redeem_profile_referral_invite_code(user_id, invite_code, updated_at_micros as i64) + .await + { + Ok(record) => Some(PhoneLoginReferralResponse { + ok: true, + message: Some("邀请码已绑定".to_string()), + invitee_reward_granted: record.invitee_reward_granted, + inviter_reward_granted: record.inviter_reward_granted, + invitee_balance_after: Some(record.invitee_balance_after), + inviter_balance_after: Some(record.inviter_balance_after), + }), + Err(error) => { + warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + error = %error, + "注册邀请码绑定失败,登录流程继续" + ); + Some(PhoneLoginReferralResponse { + ok: false, + message: Some("邀请码无效,已继续注册".to_string()), + invitee_reward_granted: false, + inviter_reward_granted: false, + invitee_balance_after: None, + inviter_balance_after: None, + }) + } + } +} + fn map_phone_auth_scene(raw_scene: Option<&str>) -> Result { match raw_scene.unwrap_or("login").trim() { "login" => Ok(PhoneAuthScene::Login), diff --git a/server-rs/crates/api-server/src/profile_identity.rs b/server-rs/crates/api-server/src/profile_identity.rs new file mode 100644 index 00000000..fc5365f2 --- /dev/null +++ b/server-rs/crates/api-server/src/profile_identity.rs @@ -0,0 +1,105 @@ +use axum::{ + Json, + extract::{Extension, State}, + http::StatusCode, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use image::GenericImageView; +use module_auth::{PasswordEntryError, UpdateProfileInput}; +use shared_contracts::auth::{ProfileUpdateRequest, ProfileUpdateResponse}; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, + auth_payload::map_auth_user_payload, http_error::AppError, request_context::RequestContext, + state::AppState, +}; + +const MAX_AVATAR_BYTES: usize = 5 * 1024 * 1024; +const AVATAR_SIZE_PX: u32 = 256; + +pub async fn update_profile_identity( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + if let Some(avatar_data_url) = payload.avatar_data_url.as_deref() { + validate_avatar_data_url(avatar_data_url)?; + } + + let result = state + .password_entry_service() + .update_profile(UpdateProfileInput { + user_id: authenticated.claims().user_id().to_string(), + display_name: payload.display_name, + avatar_url: payload.avatar_data_url, + }) + .map_err(map_profile_update_error)?; + + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) + })?; + + Ok(json_success_body( + Some(&request_context), + ProfileUpdateResponse { + user: map_auth_user_payload(result.user), + }, + )) +} + +fn validate_avatar_data_url(value: &str) -> Result<(), AppError> { + let Some((header, payload)) = value.trim().split_once(',') else { + return Err(invalid_avatar_error("头像图片格式不正确")); + }; + if !matches!( + header, + "data:image/png;base64" | "data:image/jpeg;base64" | "data:image/webp;base64" + ) { + return Err(invalid_avatar_error("头像仅支持 jpg、png、webp")); + } + + let bytes = BASE64_STANDARD + .decode(payload) + .map_err(|_| invalid_avatar_error("头像图片格式不正确"))?; + if bytes.len() > MAX_AVATAR_BYTES { + return Err(invalid_avatar_error("头像图片不能超过 5MB")); + } + + let image = + image::load_from_memory(&bytes).map_err(|_| invalid_avatar_error("头像图片格式不正确"))?; + let (width, height) = image.dimensions(); + if width != AVATAR_SIZE_PX || height != AVATAR_SIZE_PX { + return Err(invalid_avatar_error("头像裁剪尺寸需要为 256x256")); + } + + Ok(()) +} + +fn invalid_avatar_error(message: &'static str) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message) +} + +fn map_profile_update_error(error: PasswordEntryError) -> AppError { + match error { + PasswordEntryError::InvalidDisplayName + | PasswordEntryError::InvalidAvatarDataUrl + | PasswordEntryError::EmptyProfileUpdate => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + } + PasswordEntryError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("当前登录态已失效,请重新登录"), + PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) + } + PasswordEntryError::InvalidPhoneNumber + | PasswordEntryError::InvalidPasswordLength + | PasswordEntryError::InvalidPublicUserCode + | PasswordEntryError::InvalidCredentials => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + } + } +} diff --git a/server-rs/crates/api-server/src/prompt/big_fish.rs b/server-rs/crates/api-server/src/prompt/big_fish.rs index a1e28fe8..919d302f 100644 --- a/server-rs/crates/api-server/src/prompt/big_fish.rs +++ b/server-rs/crates/api-server/src/prompt/big_fish.rs @@ -10,7 +10,7 @@ use crate::creation_agent_anchor_templates::{ }; use crate::creation_agent_chat::render_quick_fill_extra_rules; -pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创“大鱼吃小鱼”竖屏玩法的中文创意策划。 +pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和百梦主共创“大鱼吃小鱼”竖屏玩法的中文创意策划。 你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。 diff --git a/server-rs/crates/api-server/src/prompt/mod.rs b/server-rs/crates/api-server/src/prompt/mod.rs index e00f1991..026a6877 100644 --- a/server-rs/crates/api-server/src/prompt/mod.rs +++ b/server-rs/crates/api-server/src/prompt/mod.rs @@ -1,7 +1,7 @@ pub(crate) mod big_fish; pub(crate) mod character_animation; pub(crate) mod character_visual; -pub(crate) mod puzzle_image; +pub(crate) mod puzzle; pub(crate) mod rpg; pub(crate) mod scene_background; diff --git a/server-rs/crates/api-server/src/prompt/puzzle/agent_chat.rs b/server-rs/crates/api-server/src/prompt/puzzle/agent_chat.rs new file mode 100644 index 00000000..bea5941c --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/puzzle/agent_chat.rs @@ -0,0 +1,212 @@ +use module_puzzle::{PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack}; +use serde_json::{Value as JsonValue, json}; +use spacetime_client::{ + PuzzleAgentMessageRecord, PuzzleAgentSessionRecord, PuzzleAnchorPackRecord, +}; + +use crate::creation_agent_anchor_templates::{ + get_creation_agent_anchor_template, render_anchor_question_block, +}; +use crate::creation_agent_chat::render_quick_fill_extra_rules; + +/// 拼图共创 Agent 的系统提示词。 +/// +/// 这里作为拼图聊天提示词主源,业务文件只负责调用 LLM、解析结果和写回状态。 +pub(crate) const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和百梦主共创拼图画面的中文创意策划。 + +你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。 + +你必须同时输出: +1. 一段直接发给用户的中文回复 replyText +2. 当前进度 progressPercent +3. 下一轮完整可用的 nextAnchorPack + +硬约束: +1. 只能输出 JSON,不能输出代码块或解释 +2. nextAnchorPack 必须是完整对象,不能只输出 patch +3. replyText 必须是自然中文,不能提“字段”“锚点”“结构”“JSON”等内部词 +4. replyText 一次最多推进一个最关键问题 +5. 如果用户已经给出明确方向,就优先吸收和收束,不要机械反问 +6. progressPercent 范围只能是 0 到 100 +7. status 只能使用 missing / inferred / confirmed / locked +"#; + +/// 拼图共创 Agent 单轮 JSON 输出契约。 +const PUZZLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字: +{ + "replyText": "", + "progressPercent": 0, + "nextAnchorPack": { + "themePromise": { + "key": "themePromise", + "label": "题材承诺", + "value": "", + "status": "missing" + }, + "visualSubject": { + "key": "visualSubject", + "label": "画面主体", + "value": "", + "status": "missing" + }, + "visualMood": { + "key": "visualMood", + "label": "视觉气质", + "value": "", + "status": "missing" + }, + "compositionHooks": { + "key": "compositionHooks", + "label": "拼图记忆点", + "value": "", + "status": "missing" + }, + "tagsAndForbidden": { + "key": "tagsAndForbidden", + "label": "标签与禁忌", + "value": "", + "status": "missing" + } + } +}"#; + +/// 拼图共创 Agent 的用户提示词,用于触发模型按系统约定返回单轮 JSON。 +pub(crate) const PUZZLE_AGENT_JSON_TURN_USER_PROMPT: &str = "请按约定输出这一轮的 JSON。"; + +/// 拼图草稿生成对话提示词脚本。 +pub(crate) fn build_puzzle_agent_prompt( + session: &PuzzleAgentSessionRecord, + quick_fill_requested: bool, +) -> String { + let anchor_question_block = get_creation_agent_anchor_template("puzzle") + .map(render_anchor_question_block) + .unwrap_or_else(|| "模板目标:收束成可以发布为拼图关卡的视觉方案。".to_string()); + let quick_fill_rules = if quick_fill_requested { + format!( + "\n\n{}", + render_quick_fill_extra_rules( + "当前题材方向里的拼图关键词", + "不要要求用户再提供素材、风格或禁忌", + "输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项", + "生成结果页", + ) + ) + } else { + String::new() + }; + format!( + "{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}", + anchor_question_block = anchor_question_block, + quick_fill_rules = quick_fill_rules, + turn = session.current_turn.saturating_add(1), + progress = session.progress_percent, + quick_fill_requested_text = if quick_fill_requested { "是" } else { "否" }, + anchor_pack = serialize_puzzle_record_anchor_pack(&session.anchor_pack), + chat_history = + serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice())) + .unwrap_or_else(|_| "[]".to_string()), + contract = PUZZLE_AGENT_OUTPUT_CONTRACT, + ) +} + +/// 将 SpacetimeDB 记录态锚点序列化成提示词可读 JSON。 +pub(crate) fn serialize_puzzle_record_anchor_pack(record: &PuzzleAnchorPackRecord) -> String { + serde_json::to_string_pretty(&map_puzzle_record_anchor_pack(record)).unwrap_or_else(|_| { + serde_json::to_string_pretty(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string()) + }) +} + +fn build_chat_history(messages: &[PuzzleAgentMessageRecord]) -> Vec { + messages + .iter() + .map(|message| { + json!({ + "role": message.role, + "kind": message.kind, + "content": message.text, + }) + }) + .collect() +} + +fn map_puzzle_record_anchor_pack(record: &PuzzleAnchorPackRecord) -> PuzzleAnchorPack { + PuzzleAnchorPack { + theme_promise: map_puzzle_record_anchor_item(&record.theme_promise), + visual_subject: map_puzzle_record_anchor_item(&record.visual_subject), + visual_mood: map_puzzle_record_anchor_item(&record.visual_mood), + composition_hooks: map_puzzle_record_anchor_item(&record.composition_hooks), + tags_and_forbidden: map_puzzle_record_anchor_item(&record.tags_and_forbidden), + } +} + +fn map_puzzle_record_anchor_item( + record: &spacetime_client::PuzzleAnchorItemRecord, +) -> module_puzzle::PuzzleAnchorItem { + module_puzzle::PuzzleAnchorItem { + key: record.key.clone(), + label: record.label.clone(), + value: record.value.clone(), + status: parse_puzzle_anchor_status(record.status.as_str()), + } +} + +fn parse_puzzle_anchor_status(value: &str) -> PuzzleAnchorStatus { + match value { + "confirmed" => PuzzleAnchorStatus::Confirmed, + "locked" => PuzzleAnchorStatus::Locked, + "inferred" => PuzzleAnchorStatus::Inferred, + _ => PuzzleAnchorStatus::Missing, + } +} + +#[cfg(test)] +mod tests { + use super::build_puzzle_agent_prompt; + + fn anchor_item( + key: &str, + label: &str, + value: &str, + status: &str, + ) -> spacetime_client::PuzzleAnchorItemRecord { + spacetime_client::PuzzleAnchorItemRecord { + key: key.to_string(), + label: label.to_string(), + value: value.to_string(), + status: status.to_string(), + } + } + + fn empty_session_record() -> spacetime_client::PuzzleAgentSessionRecord { + spacetime_client::PuzzleAgentSessionRecord { + session_id: "puzzle-session-test".to_string(), + seed_text: "雨夜猫咪遗迹".to_string(), + current_turn: 2, + progress_percent: 60, + stage: "collecting_anchors".to_string(), + anchor_pack: spacetime_client::PuzzleAnchorPackRecord { + theme_promise: anchor_item("themePromise", "题材承诺", "雨夜猫咪遗迹", "confirmed"), + visual_subject: anchor_item("visualSubject", "画面主体", "", "missing"), + visual_mood: anchor_item("visualMood", "视觉气质", "", "missing"), + composition_hooks: anchor_item("compositionHooks", "拼图记忆点", "", "missing"), + tags_and_forbidden: anchor_item("tagsAndForbidden", "标签与禁忌", "", "missing"), + }, + draft: None, + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + suggested_actions: Vec::new(), + result_preview: None, + updated_at: "2026-04-24T10:00:00.000Z".to_string(), + } + } + + #[test] + fn quick_fill_prompt_forbids_follow_up_questions() { + let prompt = build_puzzle_agent_prompt(&empty_session_record(), true); + + assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字")); + assert!(prompt.contains("不要再继续提问")); + assert!(prompt.contains("progressPercent 直接输出为 100")); + } +} diff --git a/server-rs/crates/api-server/src/prompt/puzzle/draft.rs b/server-rs/crates/api-server/src/prompt/puzzle/draft.rs new file mode 100644 index 00000000..ada339c0 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/puzzle/draft.rs @@ -0,0 +1,86 @@ +/// 拼图作品草稿生成动作的提示词主源。 +/// +/// 拼图结果页草稿本体仍由 SpacetimeDB reducer 按表单/锚点确定性编译; +/// 这里收口 api-server 在生成草稿前后需要写入 reducer 的表单 seed 文本, +/// 以及草稿首图生成时的 prompt 来源选择,避免业务路由直接拼提示词文本。 +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct PuzzleFormSeedPromptParts<'a> { + pub(crate) title: Option<&'a str>, + pub(crate) work_description: Option<&'a str>, + pub(crate) picture_description: Option<&'a str>, +} + +/// 将填表式拼图输入编译成 SpacetimeDB 可恢复的表单 seed prompt。 +pub(crate) fn build_puzzle_form_seed_prompt(parts: PuzzleFormSeedPromptParts<'_>) -> String { + [ + ("作品名称", normalize_prompt_part(parts.title)), + ("作品描述", normalize_prompt_part(parts.work_description)), + ("画面描述", normalize_prompt_part(parts.picture_description)), + ] + .into_iter() + .filter_map(|(label, value)| value.map(|value| format!("{label}:{value}"))) + .collect::>() + .join("\n") +} + +/// 生成作品草稿时,首图 prompt 优先使用玩家当前表单里的画面描述。 +pub(crate) fn resolve_puzzle_draft_cover_prompt( + explicit_prompt: Option<&str>, + level_picture_description: &str, + draft_summary: &str, +) -> String { + normalize_prompt_part(explicit_prompt) + .or_else(|| normalize_prompt_part(Some(level_picture_description))) + .or_else(|| normalize_prompt_part(Some(draft_summary))) + .unwrap_or_default() + .to_string() +} + +/// 结果页单关重新生成时,优先使用面板当前编辑态 prompt,再回退关卡画面描述。 +pub(crate) fn resolve_puzzle_level_image_prompt( + explicit_prompt: Option<&str>, + level_picture_description: &str, +) -> String { + normalize_prompt_part(explicit_prompt) + .or_else(|| normalize_prompt_part(Some(level_picture_description))) + .unwrap_or_default() + .to_string() +} + +fn normalize_prompt_part(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn form_seed_prompt_keeps_only_user_visible_fields() { + let prompt = build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { + title: Some(" 暖灯猫街 "), + work_description: Some("雨夜礼物拼图"), + picture_description: Some("猫咪在灯牌下回头"), + }); + + assert_eq!( + prompt, + "作品名称:暖灯猫街\n作品描述:雨夜礼物拼图\n画面描述:猫咪在灯牌下回头" + ); + } + + #[test] + fn draft_cover_prompt_prefers_current_picture_description() { + let prompt = + resolve_puzzle_draft_cover_prompt(Some(" 当前表单画面 "), "旧关卡画面", "作品简介"); + + assert_eq!(prompt, "当前表单画面"); + } + + #[test] + fn level_image_prompt_falls_back_to_level_description() { + let prompt = resolve_puzzle_level_image_prompt(Some(" "), "关卡画面描述"); + + assert_eq!(prompt, "关卡画面描述"); + } +} diff --git a/server-rs/crates/api-server/src/prompt/puzzle/image.rs b/server-rs/crates/api-server/src/prompt/puzzle/image.rs new file mode 100644 index 00000000..04bfd9e2 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/puzzle/image.rs @@ -0,0 +1,103 @@ +/// 拼图图片生成的默认反向提示词。 +/// +/// 这里单独收口拼图图片提示词,避免图片生成链路、候选图持久化和 DashScope 请求编排 +/// 混在同一个脚本里,后续调画风或资产约束时只需要改这一处。 +pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str = + "低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污"; + +/// wan2.2 / wan2.1 文生图旧协议的正向 prompt 上限。 +/// +/// 中文注释:DashScope 旧 text2image 接口会把超长 prompt 判成请求参数不合法, +/// 所以这里先在拼图提示词模块内压缩,保证固定玩法约束不会被用户长描述挤掉。 +pub(crate) const PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS: usize = 500; + +const PUZZLE_IMAGE_LEVEL_NAME_MAX_CHARS: usize = 40; +const PUZZLE_IMAGE_PROMPT_FALLBACK: &str = "清晰、有辨识度的拼图画面"; + +/// 根据拼图关卡名和百梦主输入构造最终发给图片模型的提示词。 +pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String { + let level_name = + truncate_puzzle_prompt_segment(level_name.trim(), PUZZLE_IMAGE_LEVEL_NAME_MAX_CHARS); + let prompt = prompt.trim(); + let prompt = if prompt.is_empty() { + PUZZLE_IMAGE_PROMPT_FALLBACK + } else { + prompt + }; + let template_chars = build_puzzle_image_prompt_text(level_name.as_str(), "") + .chars() + .count(); + let prompt_max_chars = PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS.saturating_sub(template_chars); + let prompt = truncate_puzzle_prompt_segment(prompt, prompt_max_chars); + let image_prompt = build_puzzle_image_prompt_text(level_name.as_str(), prompt.as_str()); + + debug_assert!( + image_prompt.chars().count() <= PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS, + "puzzle image prompt should fit DashScope wan2.2 limit" + ); + image_prompt +} + +fn build_puzzle_image_prompt_text(_level_name: &str, prompt: &str) -> String { + format!( + concat!( + "请生成一张高清插画。", + "画面主体:{prompt}。", + "画面要求:1:1", + "主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,", + "避免文字、水印、边框和 UI 元素。" + ), + prompt = prompt, + ) +} + +fn truncate_puzzle_prompt_segment(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + + const MARKER: &str = "..."; + if max_chars <= MARKER.chars().count() { + return value.chars().take(max_chars).collect(); + } + + let keep_chars = max_chars - MARKER.chars().count(); + format!( + "{}{MARKER}", + value.chars().take(keep_chars).collect::() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_puzzle_image_prompt_keeps_puzzle_asset_constraints() { + let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索"); + + assert!(prompt.contains("猫咪在发光遗迹前寻找线索")); + assert!(prompt.contains("1:1")); + assert!(prompt.contains("主体要清晰集中")); + assert!(prompt.contains("避免文字、水印、边框和 UI 元素")); + } + + #[test] + fn build_puzzle_image_prompt_trims_long_user_description_for_wan22() { + let long_level_name = "雨夜神庙".repeat(20); + let long_description = + "发光遗迹、猫咪、漂浮碎片、雨水反光、远处灯塔、适合拼图切块。".repeat(50); + let prompt = build_puzzle_image_prompt(long_level_name.as_str(), long_description.as_str()); + + assert!(prompt.chars().count() <= PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS); + assert!(prompt.contains("1:1")); + assert!(prompt.contains("主体要清晰集中")); + assert!(prompt.contains("避免文字、水印、边框和 UI 元素")); + } + + #[test] + fn default_negative_prompt_blocks_text_and_low_quality_assets() { + assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("低清晰度")); + assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("文字水印")); + } +} diff --git a/server-rs/crates/api-server/src/prompt/puzzle/mod.rs b/server-rs/crates/api-server/src/prompt/puzzle/mod.rs new file mode 100644 index 00000000..c579b9c0 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/puzzle/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod agent_chat; +pub(crate) mod draft; +pub(crate) mod image; diff --git a/server-rs/crates/api-server/src/prompt/puzzle_image.rs b/server-rs/crates/api-server/src/prompt/puzzle_image.rs deleted file mode 100644 index 89645b5c..00000000 --- a/server-rs/crates/api-server/src/prompt/puzzle_image.rs +++ /dev/null @@ -1,44 +0,0 @@ -/// 拼图图片生成的默认反向提示词。 -/// -/// 这里单独收口拼图图片提示词,避免图片生成链路、候选图持久化和 DashScope 请求编排 -/// 混在同一个脚本里,后续调画风或资产约束时只需要改这一处。 -pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str = - "低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污"; - -/// 根据拼图关卡名和创作者输入构造最终发给图片模型的提示词。 -pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String { - format!( - concat!( - "请生成一张适合正方形拼图关卡的高清插画。", - "关卡名:{level_name}。", - "画面主体:{prompt}。", - "画面要求:1:1 正方形画布,适配 3x3 或 4x4 拼图切块,", - "主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,", - "避免文字、水印、边框和 UI 元素。" - ), - level_name = level_name, - prompt = prompt, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn build_puzzle_image_prompt_keeps_puzzle_asset_constraints() { - let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索"); - - assert!(prompt.contains("雨夜神庙")); - assert!(prompt.contains("猫咪在发光遗迹前寻找线索")); - assert!(prompt.contains("正方形拼图关卡")); - assert!(prompt.contains("3x3 或 4x4")); - assert!(prompt.contains("避免文字、水印、边框和 UI 元素")); - } - - #[test] - fn default_negative_prompt_blocks_text_and_low_quality_assets() { - assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("低清晰度")); - assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("文字水印")); - } -} diff --git a/server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs b/server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs index 4e8e6ea7..f02d87eb 100644 --- a/server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs +++ b/server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs @@ -240,7 +240,10 @@ JSON 结构: - functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。 - functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId,不写规则说明。 - 非敌对聊天 shouldEndChat 必须为 false。 -- 敌对聊天可以随时 shouldEndChat=true,且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#; +- 敌对聊天可以随时 shouldEndChat=true。 +- 敌对 NPC 感知到玩家负面发言时,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,应倾向立即 shouldEndChat=true。 +- 敌对 NPC 已聊天轮次达到 4 轮或以上时,本轮结束后会超过 4 轮,应倾向立即 shouldEndChat=true。 +- shouldEndChat=true 时 terminationReason 使用 hostile_breakoff,suggestions 与 functionSuggestions 可以为空。"#; #[derive(Debug)] pub(crate) struct NpcChatTurnPromptInput<'a> { @@ -394,6 +397,19 @@ pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput< } else { None }, + if is_hostile_model_chat { + Some("如果玩家刚才的话被 NPC 感知为负面发言,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,本轮回复应倾向写成最后通牒、驱逐前警告或战斗前狠话。".to_string()) + } else { + None + }, + if is_hostile_model_chat && chatted_count >= 4.0 { + Some(format!( + "敌对聊天已持续 {} 轮,本轮结束后会超过 4 轮;回复应明显倾向立即收束,像开战前最后一句狠话,而不是继续闲聊。", + format_prompt_number(chatted_count) + )) + } else { + None + }, if is_player_exit_turn { Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string()) } else { @@ -474,6 +490,9 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt( .and_then(|record| read_string(record.get("terminationReason"))) .as_deref() == Some("player_exit"); + let chatted_count = as_record(payload.npc_state) + .and_then(|record| read_number(record.get("chattedCount"))) + .unwrap_or(0.0); let function_options_block = chat_directive .and_then(|record| record.get("functionOptions")) .map(describe_function_options) @@ -498,6 +517,14 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt( } else { Some("这是非敌对聊天,shouldEndChat 必须为 false。".to_string()) }, + if is_hostile_model_chat { + Some(format!( + "敌对聊天判定:已聊天轮次为 {}。若玩家刚才的话可被 NPC 感知为负面发言,或已聊天轮次达到 4 轮及以上,本轮应倾向 shouldEndChat=true,并使用 terminationReason=hostile_breakoff。", + format_prompt_number(chatted_count) + )) + } else { + None + }, if is_player_exit_turn { Some("玩家已经选择结束聊天,shouldEndChat 必须为 true,terminationReason 必须为 player_exit。".to_string()) } else { @@ -526,6 +553,20 @@ pub(crate) fn build_deterministic_npc_reply( format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”") } +pub(crate) fn build_deterministic_hostile_breakoff_reply( + npc_name: &str, + player_message: &str, +) -> String { + // 中文注释:当模型不可用而敌对聊天必须中止时,兜底文案也保持“战斗前狠话”的语气。 + let player_signal = player_message.trim(); + if player_signal.is_empty() { + return format!("{npc_name}冷声说道:“话已经够多了。再往前一步,就别指望还能全身而退。”"); + } + format!( + "{npc_name}冷声说道:“{player_signal}?话已经够多了。再往前一步,就别指望还能全身而退。”" + ) +} + pub(crate) fn build_character_chat_reply_fallback( target_character: &Value, player_message: &str, @@ -1066,3 +1107,55 @@ fn format_prompt_number(value: f64) -> String { value.to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn hostile_prompt_input(npc_state: Value) -> NpcChatTurnPromptInput<'static> { + NpcChatTurnPromptInput { + world_type: "CUSTOM", + character: Box::leak(Box::new(Value::Null)), + encounter: Box::leak(Box::new(Value::Null)), + monsters: &[], + history: &[], + context: Box::leak(Box::new(Value::Null)), + conversation_history: &[], + dialogue: &[], + combat_context: None, + player_message: "少废话,让开。", + npc_state: Box::leak(Box::new(npc_state)), + npc_initiates_conversation: false, + chat_directive: Some(Box::leak(Box::new(json!({ + "terminationMode": "hostile_model", + "isHostileChat": true, + })))), + } + } + + #[test] + fn hostile_reply_prompt_mentions_final_threat_after_four_turns() { + let input = hostile_prompt_input(json!({ + "affinity": -12, + "chattedCount": 4, + })); + let prompt = build_npc_chat_turn_reply_prompt(&input); + + assert!(prompt.contains("已聊天轮次:4")); + assert!(prompt.contains("战斗前狠话")); + assert!(prompt.contains("本轮结束后会超过 4 轮")); + } + + #[test] + fn hostile_suggestion_prompt_mentions_should_end_chat_signals() { + let input = hostile_prompt_input(json!({ + "affinity": -12, + "chattedCount": 4, + })); + let prompt = build_npc_chat_turn_suggestion_prompt(&input, "再往前一步,就别想回头。"); + + assert!(prompt.contains("shouldEndChat=true")); + assert!(prompt.contains("terminationReason=hostile_breakoff")); + assert!(prompt.contains("已聊天轮次为 4")); + } +} diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 125d21a3..7c819e37 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -17,7 +17,7 @@ use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; -use module_puzzle::PuzzleGeneratedImageCandidate; +use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus}; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, @@ -29,17 +29,18 @@ use shared_contracts::{ PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse, PuzzleAgentSessionResponse, PuzzleAgentSessionSnapshotResponse, PuzzleAgentSuggestedActionResponse, PuzzleAnchorItemResponse, PuzzleAnchorPackResponse, - PuzzleCreatorIntentResponse, PuzzleGeneratedImageCandidateResponse, - PuzzleResultDraftResponse, PuzzleResultPreviewBlockerResponse, - PuzzleResultPreviewEnvelopeResponse, PuzzleResultPreviewFindingResponse, - SendPuzzleAgentMessageRequest, + PuzzleCreatorIntentResponse, PuzzleDraftLevelResponse, PuzzleFormDraftResponse, + PuzzleGeneratedImageCandidateResponse, PuzzleResultDraftResponse, + PuzzleResultPreviewBlockerResponse, PuzzleResultPreviewEnvelopeResponse, + PuzzleResultPreviewFindingResponse, SendPuzzleAgentMessageRequest, }, puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse}, puzzle_runtime::{ DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, - PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, - StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, + PuzzleRecommendedNextWorkResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse, + PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, + SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest, }, puzzle_works::{ PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, @@ -51,13 +52,16 @@ use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, + PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, + PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, - PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord, - PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, - SpacetimeClientError, + PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, + PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, + PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, + PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, + PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, + PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, + PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; use std::convert::Infallible; use tokio::time::sleep; @@ -69,13 +73,20 @@ use crate::{ auth::AuthenticatedAccessToken, http_error::AppError, platform_errors::map_oss_error, - prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt}, + prompt::puzzle::{ + draft::{ + PuzzleFormSeedPromptParts, build_puzzle_form_seed_prompt, + resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt, + }, + image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt}, + }, puzzle_agent_turn::{ PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input, run_puzzle_agent_turn, }, request_context::RequestContext, state::AppState, + work_author::resolve_work_author_by_user_id, }; const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent"; @@ -84,6 +95,7 @@ const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery"; const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime"; const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash"; const PUZZLE_ENTITY_KIND: &str = "puzzle_work"; +const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024"; pub async fn create_puzzle_agent_session( State(state): State, @@ -102,7 +114,7 @@ pub async fn create_puzzle_agent_session( ) })?; - let seed_text = payload.seed_text.unwrap_or_default().trim().to_string(); + let seed_text = build_puzzle_form_seed_text(&payload); let session = state .spacetime_client() .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { @@ -442,8 +454,44 @@ pub async fn execute_puzzle_agent_action( let now = current_utc_micros(); let action = payload.action.trim().to_string(); let billing_asset_id = format!("{session_id}:{now}"); + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + action = %action, + prompt_chars = payload + .prompt_text + .as_deref() + .map(|value| value.chars().count()) + .unwrap_or(0), + has_reference_image = payload + .reference_image_src + .as_deref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false), + "拼图 Agent action 开始执行" + ); let (operation_type, phase_label, phase_detail, session) = match action.as_str() { "compile_puzzle_draft" => { + let prompt_text = payload + .picture_description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| payload.prompt_text.as_deref()); + let compile_session_id = match save_puzzle_form_payload_before_compile( + &state, + &request_context, + &session_id, + &owner_user_id, + &payload, + now, + ) + .await + { + Ok(next_session_id) => next_session_id, + Err(response) => return Err(response), + }; let session = execute_billable_asset_operation( &state, &owner_user_id, @@ -452,12 +500,14 @@ pub async fn execute_puzzle_agent_action( async { compile_puzzle_draft_with_initial_cover( &state, - session_id.clone(), + compile_session_id.clone(), owner_user_id.clone(), + prompt_text, + payload.reference_image_src.as_deref(), now, ) .await - .map_err(map_puzzle_client_error) + .map_err(map_puzzle_compile_error) }, ) .await @@ -471,37 +521,106 @@ pub async fn execute_puzzle_agent_action( session, ) } + "save_puzzle_form_draft" => { + let seed_text = build_puzzle_form_seed_text_from_parts( + payload.work_title.as_deref(), + payload.work_description.as_deref(), + payload + .picture_description + .as_deref() + .or(payload.prompt_text.as_deref()), + ); + let save_result = state + .spacetime_client() + .save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + seed_text, + saved_at_micros: now, + }) + .await; + let session = match save_result { + Ok(session) => Ok(session), + Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => { + // 中文注释:Maincloud 旧 wasm 缺少该自动保存 procedure 时,返回当前 session,避免填表页被非关键错误打断。 + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + error = %error, + "拼图表单自动保存 procedure 缺失,降级返回当前会话" + ); + state + .spacetime_client() + .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|fallback_error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(fallback_error), + ) + }) + } + Err(error) => Err(puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + )), + }; + ( + "save_puzzle_form_draft", + "表单草稿保存", + "拼图表单草稿已保存。", + session, + ) + } "generate_puzzle_images" => { + let target_level_id = payload.level_id.clone(); + let levels_json = normalize_puzzle_levels_json_for_module( + payload.levels_json.as_deref(), + ) + .map_err(|message| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": message, + })) + }); let session = execute_billable_asset_operation( &state, &owner_user_id, "puzzle_generated_image", &billing_asset_id, async { + let levels_json = levels_json?; let session = state .spacetime_client() .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(map_puzzle_client_error)?; - let draft = session.draft.clone().ok_or_else(|| { + let mut draft = session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图结果页草稿尚未生成", })) })?; - let prompt = payload - .prompt_text - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| draft.summary.clone()); + if let Some(levels_json) = levels_json.as_ref() { + draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; + } + let target_level = + select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; + let prompt = resolve_puzzle_level_image_prompt( + payload.prompt_text.as_deref(), + &target_level.picture_description, + ); // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 let candidate_count = 1; - let candidate_start_index = draft.candidates.len(); + let candidate_start_index = target_level.candidates.len(); let candidates = generate_puzzle_image_candidates( &state, owner_user_id.as_str(), &session.session_id, - &draft.level_name, + &target_level.level_name, &prompt, payload.reference_image_src.as_deref(), candidate_count, @@ -531,6 +650,8 @@ pub async fn execute_puzzle_agent_action( .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { session_id: session.session_id, owner_user_id: owner_user_id.clone(), + level_id: Some(target_level.level_id), + levels_json, candidates_json, saved_at_micros: now, }) @@ -566,6 +687,7 @@ pub async fn execute_puzzle_agent_action( .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), + level_id: payload.level_id.clone(), candidate_id, selected_at_micros: now, }) @@ -585,6 +707,19 @@ pub async fn execute_puzzle_agent_action( ) } "publish_puzzle_work" => { + let levels_json = normalize_puzzle_levels_json_for_module( + payload.levels_json.as_deref(), + ) + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": error, + })), + ) + })?; let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); let author_display_name = resolve_author_display_name(&state, &authenticated); let profile = execute_billable_asset_operation( @@ -602,9 +737,12 @@ pub async fn execute_puzzle_agent_action( work_id: work_id.clone(), profile_id, author_display_name, + work_title: payload.work_title.clone(), + work_description: payload.work_description.clone(), level_name: payload.level_name.clone(), summary: payload.summary.clone(), theme_tags: payload.theme_tags.clone(), + levels_json, published_at_micros: now, }) .await @@ -694,7 +832,7 @@ pub async fn get_puzzle_works( PuzzleWorksResponse { items: items .into_iter() - .map(map_puzzle_work_summary_response) + .map(|item| map_puzzle_work_summary_response(&state, item)) .collect(), }, )) @@ -728,7 +866,7 @@ pub async fn get_puzzle_work_detail( Ok(json_success_body( Some(&request_context), PuzzleWorkDetailResponse { - item: map_puzzle_work_profile_response(item), + item: map_puzzle_work_profile_response(&state, item), }, )) } @@ -762,11 +900,17 @@ pub async fn put_puzzle_work( .update_puzzle_work(PuzzleWorkUpsertRecordInput { profile_id, owner_user_id: authenticated.claims().user_id().to_string(), + work_title: payload.work_title, + work_description: payload.work_description, level_name: payload.level_name, summary: payload.summary, theme_tags: payload.theme_tags, cover_image_src: payload.cover_image_src, cover_asset_id: payload.cover_asset_id, + levels_json: Some(serialize_puzzle_levels_response( + &request_context, + &payload.levels, + )?), updated_at_micros: current_utc_micros(), }) .await @@ -781,7 +925,7 @@ pub async fn put_puzzle_work( Ok(json_success_body( Some(&request_context), PuzzleWorkMutationResponse { - item: map_puzzle_work_profile_response(item), + item: map_puzzle_work_profile_response(&state, item), }, )) } @@ -816,12 +960,49 @@ pub async fn delete_puzzle_work( PuzzleWorksResponse { items: items .into_iter() - .map(map_puzzle_work_summary_response) + .map(|item| map_puzzle_work_summary_response(&state, item)) .collect(), }, )) } +pub async fn claim_puzzle_work_point_incentive( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .claim_puzzle_work_point_incentive(PuzzleWorkPointIncentiveClaimRecordInput { + profile_id, + owner_user_id: authenticated.claims().user_id().to_string(), + claimed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorkMutationResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + pub async fn list_puzzle_gallery( State(state): State, Extension(request_context): Extension, @@ -843,7 +1024,7 @@ pub async fn list_puzzle_gallery( PuzzleGalleryResponse { items: items .into_iter() - .map(map_puzzle_work_summary_response) + .map(|item| map_puzzle_work_summary_response(&state, item)) .collect(), }, )) @@ -876,7 +1057,87 @@ pub async fn get_puzzle_gallery_detail( Ok(json_success_body( Some(&request_context), PuzzleGalleryDetailResponse { - item: map_puzzle_work_summary_response(item), + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn record_puzzle_gallery_like( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_GALLERY_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .record_puzzle_work_like(PuzzleWorkLikeReportRecordInput { + profile_id, + user_id: authenticated.claims().user_id().to_string(), + liked_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleGalleryDetailResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn remix_puzzle_gallery_work( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_GALLERY_PROVIDER, + &profile_id, + "profileId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let session = state + .spacetime_client() + .remix_puzzle_work(PuzzleWorkRemixRecordInput { + source_profile_id: profile_id, + target_owner_user_id: owner_user_id, + target_session_id: build_prefixed_uuid_id("puzzle-session-"), + target_profile_id: build_prefixed_uuid_id("puzzle-profile-"), + target_work_id: build_prefixed_uuid_id("puzzle-work-"), + author_display_name: resolve_author_display_name(&state, &authenticated), + welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), + remixed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleAgentSessionResponse { + session: map_puzzle_agent_session_response(session), }, )) } @@ -910,6 +1171,7 @@ pub async fn start_puzzle_run( run_id: build_prefixed_uuid_id("puzzle-run-"), owner_user_id: authenticated.claims().user_id().to_string(), profile_id: payload.profile_id, + level_id: payload.level_id, started_at_micros: current_utc_micros(), }) .await @@ -924,7 +1186,7 @@ pub async fn start_puzzle_run( Ok(json_success_body( Some(&request_context), PuzzleRunResponse { - run: map_puzzle_run_response(run), + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } @@ -952,7 +1214,7 @@ pub async fn get_puzzle_run( Ok(json_success_body( Some(&request_context), PuzzleRunResponse { - run: map_puzzle_run_response(run), + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } @@ -1009,59 +1271,7 @@ pub async fn swap_puzzle_pieces( Ok(json_success_body( Some(&request_context), PuzzleRunResponse { - run: map_puzzle_run_response(run), - }, - )) -} - -pub async fn drag_puzzle_piece_or_group( - State(state): State, - AxumPath(run_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = payload.map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_RUNTIME_PROVIDER, - "message": error.body_text(), - })), - ) - })?; - ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; - ensure_non_empty( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - &payload.piece_id, - "pieceId", - )?; - - let run = state - .spacetime_client() - .drag_puzzle_piece_or_group(PuzzleRunDragRecordInput { - run_id, - owner_user_id: authenticated.claims().user_id().to_string(), - piece_id: payload.piece_id, - target_row: payload.target_row, - target_col: payload.target_col, - dragged_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleRunResponse { - run: map_puzzle_run_response(run), + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } @@ -1093,7 +1303,149 @@ pub async fn advance_puzzle_next_level( Ok(json_success_body( Some(&request_context), PuzzleRunResponse { - run: map_puzzle_run_response(run), + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn update_puzzle_run_pause( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .update_puzzle_run_pause(PuzzleRunPauseRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + paused: payload.paused, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn use_puzzle_runtime_prop( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.prop_kind, + "propKind", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let prop_kind = payload.prop_kind.trim().to_string(); + let billing_asset_kind = match prop_kind.as_str() { + "hint" => "puzzle_prop_hint", + "reference" => "puzzle_prop_preview", + "freezeTime" | "freeze_time" => "puzzle_prop_freeze_time", + "extendTime" | "extend_time" => "puzzle_prop_extend_time", + _ => { + return Err(puzzle_bad_request( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + "unknown puzzle prop kind", + )); + } + }; + let should_sync_freeze_boundary = matches!(prop_kind.as_str(), "freezeTime" | "freeze_time"); + let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros()); + let reducer_owner_user_id = owner_user_id.clone(); + let reducer_run_id = run_id.clone(); + let fallback_run_id = run_id.clone(); + let fallback_owner_user_id = owner_user_id.clone(); + let run_result = execute_billable_asset_operation( + &state, + &owner_user_id, + billing_asset_kind, + billing_asset_id.as_str(), + async { + state + .spacetime_client() + .use_puzzle_runtime_prop(PuzzleRunPropRecordInput { + run_id: reducer_run_id, + owner_user_id: reducer_owner_user_id, + prop_kind, + used_at_micros: current_utc_micros(), + spent_points: crate::asset_billing::ASSET_OPERATION_POINTS_COST, + }) + .await + .map_err(map_puzzle_client_error) + }, + ) + .await; + + let run = match run_result { + Ok(run) => run, + Err(error) if should_sync_puzzle_freeze_boundary(&error, should_sync_freeze_boundary) => { + // 中文注释:冻结确认窗打开时前端会暂停视觉计时,但正式 run 仍可能在服务端边界帧先结算失败。 + // 这类情况已由扣费包装器退款,此处只同步失败态快照,避免玩家看到“操作不合法”。 + state + .spacetime_client() + .get_puzzle_run(fallback_run_id, fallback_owner_user_id) + .await + .map_err(map_puzzle_client_error) + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error) + })? + } + Err(error) => { + return Err(puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + error, + )); + } + }; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } @@ -1141,7 +1493,7 @@ pub async fn submit_puzzle_leaderboard( Ok(json_success_body( Some(&request_context), PuzzleRunResponse { - run: map_puzzle_run_response(run), + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } @@ -1151,6 +1503,7 @@ fn map_puzzle_agent_session_response( ) -> PuzzleAgentSessionSnapshotResponse { PuzzleAgentSessionSnapshotResponse { session_id: session.session_id, + seed_text: session.seed_text, current_turn: session.current_turn, progress_percent: session.progress_percent, stage: session.stage, @@ -1198,6 +1551,8 @@ fn map_puzzle_anchor_item_response(anchor: PuzzleAnchorItemRecord) -> PuzzleAnch fn map_puzzle_result_draft_response(draft: PuzzleResultDraftRecord) -> PuzzleResultDraftResponse { PuzzleResultDraftResponse { + work_title: draft.work_title, + work_description: draft.work_description, level_name: draft.level_name, summary: draft.summary, theme_tags: draft.theme_tags, @@ -1213,6 +1568,37 @@ fn map_puzzle_result_draft_response(draft: PuzzleResultDraftRecord) -> PuzzleRes cover_image_src: draft.cover_image_src, cover_asset_id: draft.cover_asset_id, generation_status: draft.generation_status, + levels: draft + .levels + .into_iter() + .map(map_puzzle_draft_level_response) + .collect(), + form_draft: draft.form_draft.map(map_puzzle_form_draft_response), + } +} + +fn map_puzzle_form_draft_response(draft: PuzzleFormDraftRecord) -> PuzzleFormDraftResponse { + PuzzleFormDraftResponse { + work_title: draft.work_title, + work_description: draft.work_description, + picture_description: draft.picture_description, + } +} + +fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraftLevelResponse { + PuzzleDraftLevelResponse { + level_id: level.level_id, + level_name: level.level_name, + picture_description: level.picture_description, + candidates: level + .candidates + .into_iter() + .map(map_puzzle_generated_image_candidate_response) + .collect(), + selected_candidate_id: level.selected_candidate_id, + cover_image_src: level.cover_image_src, + cover_asset_id: level.cover_asset_id, + generation_status: level.generation_status, } } @@ -1307,13 +1693,24 @@ fn map_puzzle_result_preview_finding_response( } } -fn map_puzzle_work_summary_response(item: PuzzleWorkProfileRecord) -> PuzzleWorkSummaryResponse { +fn map_puzzle_work_summary_response( + state: &AppState, + item: PuzzleWorkProfileRecord, +) -> PuzzleWorkSummaryResponse { + let author = resolve_work_author_by_user_id( + state, + &item.owner_user_id, + Some(&item.author_display_name), + None, + ); PuzzleWorkSummaryResponse { work_id: item.work_id, profile_id: item.profile_id, owner_user_id: item.owner_user_id, source_session_id: item.source_session_id, - author_display_name: item.author_display_name, + author_display_name: author.display_name, + work_title: item.work_title, + work_description: item.work_description, level_name: item.level_name, summary: item.summary, theme_tags: item.theme_tags, @@ -1323,13 +1720,34 @@ fn map_puzzle_work_summary_response(item: PuzzleWorkProfileRecord) -> PuzzleWork updated_at: item.updated_at, published_at: item.published_at, play_count: item.play_count, + remix_count: item.remix_count, + like_count: item.like_count, + recent_play_count_7d: item.recent_play_count_7d, + point_incentive_total_half_points: item.point_incentive_total_half_points, + point_incentive_claimed_points: item.point_incentive_claimed_points, + point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0, + point_incentive_claimable_points: item + .point_incentive_total_half_points + .saturating_div(2) + .saturating_sub(item.point_incentive_claimed_points), publish_ready: item.publish_ready, + levels: Vec::new(), } } -fn map_puzzle_work_profile_response(item: PuzzleWorkProfileRecord) -> PuzzleWorkProfileResponse { +fn map_puzzle_work_profile_response( + state: &AppState, + item: PuzzleWorkProfileRecord, +) -> PuzzleWorkProfileResponse { + let mut summary = map_puzzle_work_summary_response(state, item.clone()); + summary.levels = item + .levels + .into_iter() + .map(map_puzzle_draft_level_response) + .collect(); + PuzzleWorkProfileResponse { - summary: map_puzzle_work_summary_response(item.clone()), + summary, anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack), } } @@ -1345,6 +1763,14 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse { previous_level_tags: run.previous_level_tags, current_level: run.current_level.map(map_puzzle_runtime_level_response), recommended_next_profile_id: run.recommended_next_profile_id, + next_level_mode: run.next_level_mode, + next_level_profile_id: run.next_level_profile_id, + next_level_id: run.next_level_id, + recommended_next_works: run + .recommended_next_works + .into_iter() + .map(map_puzzle_recommended_next_work_response) + .collect(), leaderboard_entries: run .leaderboard_entries .into_iter() @@ -1353,12 +1779,62 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse { } } +fn map_puzzle_recommended_next_work_response( + item: PuzzleRecommendedNextWorkRecord, +) -> PuzzleRecommendedNextWorkResponse { + PuzzleRecommendedNextWorkResponse { + profile_id: item.profile_id, + level_name: item.level_name, + author_display_name: item.author_display_name, + theme_tags: item.theme_tags, + cover_image_src: item.cover_image_src, + similarity_score: item.similarity_score, + } +} + +async fn enrich_puzzle_run_author_name( + state: &AppState, + mut run: PuzzleRunRecord, +) -> PuzzleRunRecord { + if let Some(level) = run.current_level.as_mut() { + if let Ok(profile) = state + .spacetime_client() + .get_puzzle_gallery_detail(level.profile_id.clone()) + .await + { + level.author_display_name = resolve_work_author_by_user_id( + state, + &profile.owner_user_id, + Some(&profile.author_display_name), + None, + ) + .display_name; + } + } + + run +} + fn map_puzzle_runtime_level_response( level: spacetime_client::PuzzleRuntimeLevelRecord, ) -> PuzzleRuntimeLevelSnapshotResponse { + let timer_defaults = + build_puzzle_runtime_timer_response_defaults(level.level_index, level.grid_size); + let time_limit_ms = if level.time_limit_ms == 0 { + timer_defaults.time_limit_ms + } else { + level.time_limit_ms + }; + let remaining_ms = + if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing.as_str() { + time_limit_ms + } else { + level.remaining_ms.min(time_limit_ms) + }; PuzzleRuntimeLevelSnapshotResponse { run_id: level.run_id, level_index: level.level_index, + level_id: level.level_id, grid_size: level.grid_size, profile_id: level.profile_id, level_name: level.level_name, @@ -1370,6 +1846,13 @@ fn map_puzzle_runtime_level_response( started_at_ms: level.started_at_ms, cleared_at_ms: level.cleared_at_ms, elapsed_ms: level.elapsed_ms, + time_limit_ms, + remaining_ms, + paused_accumulated_ms: level.paused_accumulated_ms, + pause_started_at_ms: level.pause_started_at_ms, + freeze_accumulated_ms: level.freeze_accumulated_ms, + freeze_started_at_ms: level.freeze_started_at_ms, + freeze_until_ms: level.freeze_until_ms, leaderboard_entries: level .leaderboard_entries .into_iter() @@ -1378,6 +1861,22 @@ fn map_puzzle_runtime_level_response( } } +struct PuzzleRuntimeTimerResponseDefaults { + time_limit_ms: u64, +} + +fn build_puzzle_runtime_timer_response_defaults( + level_index: u32, + grid_size: u32, +) -> PuzzleRuntimeTimerResponseDefaults { + let time_limit_ms = if level_index > 0 { + module_puzzle::resolve_puzzle_level_time_limit_ms_by_index(level_index) + } else { + module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size) + }; + PuzzleRuntimeTimerResponseDefaults { time_limit_ms } +} + fn map_puzzle_leaderboard_entry_response( entry: PuzzleLeaderboardEntryRecord, ) -> PuzzleLeaderboardEntryResponse { @@ -1444,10 +1943,296 @@ fn resolve_author_display_name( fn build_puzzle_welcome_text(seed_text: &str) -> String { if seed_text.trim().is_empty() { - return "先告诉我你想做一张什么样的拼图图像,我会帮你收束成可发布的题材锚点。".to_string(); + return "拼图创作信息已准备好。".to_string(); } - "我先接住你的画面灵感,再一起把它收束成正式拼图关卡。".to_string() + "拼图创作信息已准备好。".to_string() +} + +fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String { + build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { + title: payload + .work_title + .as_deref() + .or(payload.seed_text.as_deref()), + work_description: payload.work_description.as_deref(), + picture_description: payload.picture_description.as_deref(), + }) +} + +fn build_puzzle_form_seed_text_from_parts( + title: Option<&str>, + work_description: Option<&str>, + picture_description: Option<&str>, +) -> String { + build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { + title, + work_description, + picture_description, + }) +} + +async fn save_puzzle_form_payload_before_compile( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + payload: &ExecutePuzzleAgentActionRequest, + now: i64, +) -> Result { + let seed_text = build_puzzle_form_seed_text_from_parts( + payload.work_title.as_deref(), + payload.work_description.as_deref(), + payload + .picture_description + .as_deref() + .or(payload.prompt_text.as_deref()), + ); + if seed_text.trim().is_empty() { + return Ok(session_id.to_string()); + } + + let save_result = state + .spacetime_client() + .save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput { + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + seed_text: seed_text.clone(), + saved_at_micros: now, + }) + .await + .map(|_| ()); + match save_result { + Ok(()) => Ok(session_id.to_string()), + Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => { + create_seeded_puzzle_session_when_form_save_missing( + state, + request_context, + session_id, + owner_user_id, + seed_text, + now, + &error, + ) + .await + } + Err(error) => Err(puzzle_error_response( + request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + )), + } +} + +async fn create_seeded_puzzle_session_when_form_save_missing( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + seed_text: String, + now: i64, + original_error: &SpacetimeClientError, +) -> Result { + let current_session = state + .spacetime_client() + .get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string()) + .await + .map_err(|error| { + puzzle_error_response( + request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + if !current_session.seed_text.trim().is_empty() { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id, + owner_user_id, + error = %original_error, + "拼图表单草稿保存 procedure 缺失,沿用已有 seed_text 编译" + ); + return Ok(session_id.to_string()); + } + + // 中文注释:旧 Maincloud 缺自动保存 procedure 时,空 session 无法被编译;这里重建带表单 seed 的 session 保证生成主链可继续。 + let replacement_session_id = build_prefixed_uuid_id("puzzle-session-"); + let replacement = state + .spacetime_client() + .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { + session_id: replacement_session_id.clone(), + owner_user_id: owner_user_id.to_string(), + seed_text: seed_text.clone(), + welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), + welcome_message_text: build_puzzle_welcome_text(&seed_text), + created_at_micros: now, + }) + .await + .map_err(|error| { + puzzle_error_response( + request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + old_session_id = %session_id, + new_session_id = %replacement.session_id, + owner_user_id, + error = %original_error, + "拼图表单草稿保存 procedure 缺失,已创建带表单 seed 的替代 session" + ); + Ok(replacement.session_id) +} + +fn select_puzzle_level_for_api( + draft: &PuzzleResultDraftRecord, + level_id: Option<&str>, +) -> Result { + let normalized_level_id = level_id.map(str::trim).filter(|value| !value.is_empty()); + if let Some(target_id) = normalized_level_id { + return draft + .levels + .iter() + .find(|level| level.level_id == target_id) + .cloned() + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图关卡不存在:{target_id}"), + })) + }); + } + let level = draft.levels.first().cloned(); + level.ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿缺少可编辑关卡", + })) + }) +} + +fn parse_puzzle_level_records_from_module_json( + value: &str, +) -> Result, AppError> { + let levels: Vec = + serde_json::from_str(value).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图关卡列表 JSON 非法:{error}"), + })) + })?; + Ok(levels + .into_iter() + .map(|level| PuzzleDraftLevelRecord { + level_id: level.level_id, + level_name: level.level_name, + picture_description: level.picture_description, + candidates: level + .candidates + .into_iter() + .map(|candidate| PuzzleGeneratedImageCandidateRecord { + candidate_id: candidate.candidate_id, + image_src: candidate.image_src, + asset_id: candidate.asset_id, + prompt: candidate.prompt, + actual_prompt: candidate.actual_prompt, + source_type: candidate.source_type, + selected: candidate.selected, + }) + .collect(), + selected_candidate_id: level.selected_candidate_id, + cover_image_src: level.cover_image_src, + cover_asset_id: level.cover_asset_id, + generation_status: level.generation_status, + }) + .collect()) +} + +fn serialize_puzzle_levels_response( + request_context: &RequestContext, + levels: &[PuzzleDraftLevelResponse], +) -> Result { + let payload = levels + .iter() + .map(|level| { + json!({ + "level_id": level.level_id, + "level_name": level.level_name, + "picture_description": level.picture_description, + "candidates": level + .candidates + .iter() + .map(|candidate| { + json!({ + "candidate_id": candidate.candidate_id, + "image_src": candidate.image_src, + "asset_id": candidate.asset_id, + "prompt": candidate.prompt, + "actual_prompt": candidate.actual_prompt, + "source_type": candidate.source_type, + "selected": candidate.selected, + }) + }) + .collect::>(), + "selected_candidate_id": level.selected_candidate_id, + "cover_image_src": level.cover_image_src, + "cover_asset_id": level.cover_asset_id, + "generation_status": level.generation_status, + }) + }) + .collect::>(); + serde_json::to_string(&payload).map_err(|error| { + puzzle_error_response( + request_context, + PUZZLE_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_WORKS_PROVIDER, + "message": format!("拼图关卡列表序列化失败:{error}"), + })), + ) + }) +} + +fn normalize_puzzle_levels_json_for_module(value: Option<&str>) -> Result, String> { + let Some(raw) = value.map(str::trim).filter(|raw| !raw.is_empty()) else { + return Ok(None); + }; + let levels: Vec = + serde_json::from_str(raw).map_err(|error| format!("拼图关卡列表 JSON 非法:{error}"))?; + let payload = levels + .iter() + .map(|level| { + json!({ + "level_id": level.level_id, + "level_name": level.level_name, + "picture_description": level.picture_description, + "candidates": level + .candidates + .iter() + .map(|candidate| { + json!({ + "candidate_id": candidate.candidate_id, + "image_src": candidate.image_src, + "asset_id": candidate.asset_id, + "prompt": candidate.prompt, + "actual_prompt": candidate.actual_prompt, + "source_type": candidate.source_type, + "selected": candidate.selected, + }) + }) + .collect::>(), + "selected_candidate_id": level.selected_candidate_id, + "cover_image_src": level.cover_image_src, + "cover_asset_id": level.cover_asset_id, + "generation_status": level.generation_status, + }) + }) + .collect::>(); + serde_json::to_string(&payload) + .map(Some) + .map_err(|error| format!("拼图关卡列表序列化失败:{error}")) } fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) { @@ -1464,6 +2249,8 @@ async fn compile_puzzle_draft_with_initial_cover( state: &AppState, session_id: String, owner_user_id: String, + prompt_text: Option<&str>, + reference_image_src: Option<&str>, now: i64, ) -> Result { let compiled_session = state @@ -1474,16 +2261,23 @@ async fn compile_puzzle_draft_with_initial_cover( .draft .clone() .ok_or_else(|| SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string()))?; + let target_level = select_puzzle_level_for_api(&draft, None) + .map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?; + let image_prompt = resolve_puzzle_draft_cover_prompt( + prompt_text, + &target_level.picture_description, + &draft.summary, + ); // 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。 let candidates = generate_puzzle_image_candidates( state, owner_user_id.as_str(), &compiled_session.session_id, - &draft.level_name, - &draft.summary, - None, + &target_level.level_name, + &image_prompt, + reference_image_src, 1, - draft.candidates.len(), + target_level.candidates.len(), ) .await .map_err(SpacetimeClientError::Runtime)?; @@ -1505,6 +2299,8 @@ async fn compile_puzzle_draft_with_initial_cover( .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { session_id: compiled_session.session_id.clone(), owner_user_id: owner_user_id.clone(), + level_id: Some(target_level.level_id.clone()), + levels_json: None, candidates_json, saved_at_micros: current_utc_micros(), }) @@ -1514,6 +2310,7 @@ async fn compile_puzzle_draft_with_initial_cover( .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { session_id, owner_user_id, + level_id: Some(target_level.level_id), candidate_id: selected_candidate_id, selected_at_micros: current_utc_micros(), }) @@ -1577,6 +2374,67 @@ fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError { })) } +fn should_sync_puzzle_freeze_boundary(error: &AppError, is_freeze_time: bool) -> bool { + is_freeze_time && error.body_text().contains("操作不合法") +} + +fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> bool { + matches!(error, SpacetimeClientError::Procedure(message) if + message.contains("save_puzzle_form_draft") + && (message.contains("No such procedure") + || message.contains("不存在") + || message.contains("does not exist") + || message.contains("not found"))) +} + +fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { + let message = error.to_string(); + let provider = if message.contains("DashScope") || message.contains("dashscope") { + "dashscope" + } else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") { + "puzzle-assets" + } else { + "spacetimedb" + }; + let status = if matches!(error, SpacetimeClientError::Runtime(_)) + && (message.contains("生成") + || message.contains("上游") + || message.contains("DashScope") + || message.contains("dashscope") + || message.contains("参考图") + || message.contains("图片") + || message.contains("OSS") + || message.contains("oss")) + { + StatusCode::BAD_GATEWAY + } else { + match &error { + SpacetimeClientError::Procedure(message) + if message.contains("不存在") + || message.contains("not found") + || message.contains("does not exist") => + { + StatusCode::NOT_FOUND + } + SpacetimeClientError::Procedure(message) + if message.contains("当前模型不可用") + || message.contains("生成失败") + || message.contains("解析失败") + || message.contains("缺少有效回复") => + { + StatusCode::BAD_GATEWAY + } + SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::BAD_GATEWAY, + } + }; + + AppError::from_status(status).with_details(json!({ + "provider": provider, + "message": message, + })) +} + fn puzzle_error_response( request_context: &RequestContext, provider: &str, @@ -1619,6 +2477,15 @@ fn puzzle_sse_error_event_message(message: String) -> Event { Event::default().event("error").data(payload) } +fn map_puzzle_generation_app_error(error: AppError) -> String { + let body_text = error.body_text(); + if error.code() == "UPSTREAM_ERROR" { + format!("拼图图片生成失败:{body_text}") + } else { + body_text + } +} + async fn generate_puzzle_image_candidates( state: &AppState, owner_user_id: &str, @@ -1631,10 +2498,22 @@ async fn generate_puzzle_image_candidates( ) -> Result, String> { let count = candidate_count.clamp(1, 1); let settings = - require_puzzle_dashscope_settings(state).map_err(|error| error.message().to_string())?; - let http_client = build_puzzle_dashscope_http_client(&settings) - .map_err(|error| error.message().to_string())?; + require_puzzle_dashscope_settings(state).map_err(map_puzzle_generation_app_error)?; + let http_client = + build_puzzle_dashscope_http_client(&settings).map_err(map_puzzle_generation_app_error)?; let actual_prompt = build_puzzle_image_prompt(level_name, prompt); + tracing::info!( + provider = "dashscope", + session_id, + level_name, + prompt_chars = prompt.chars().count(), + actual_prompt_chars = actual_prompt.chars().count(), + has_reference_image = reference_image_src + .map(str::trim) + .map(|value| !value.is_empty()) + .unwrap_or(false), + "拼图图片生成请求已准备" + ); let reference_image = match reference_image_src .map(str::trim) .filter(|value| !value.is_empty()) @@ -1642,11 +2521,12 @@ async fn generate_puzzle_image_candidates( Some(source) => Some( resolve_puzzle_reference_image_as_data_url(state, &http_client, source) .await - .map_err(|error| error.message().to_string())?, + .map_err(map_puzzle_generation_app_error)?, ), None => None, }; // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与 DashScope 图生图都必须停留在 api-server。 + // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 let generated = match reference_image.as_deref() { Some(reference_image) => { create_puzzle_image_to_image_generation( @@ -1654,7 +2534,7 @@ async fn generate_puzzle_image_candidates( &settings, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, - "1024*1024", + PUZZLE_GENERATED_IMAGE_SIZE, count, reference_image, ) @@ -1666,13 +2546,13 @@ async fn generate_puzzle_image_candidates( &settings, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, - "1024*1024", + PUZZLE_GENERATED_IMAGE_SIZE, count, ) .await } } - .map_err(|error| error.message().to_string())?; + .map_err(map_puzzle_generation_app_error)?; let mut items = Vec::with_capacity(generated.images.len()); for (index, image) in generated.images.into_iter().enumerate() { @@ -1691,7 +2571,7 @@ async fn generate_puzzle_image_candidates( current_utc_micros(), ) .await - .map_err(|error| error.message().to_string())?; + .map_err(map_puzzle_generation_app_error)?; items.push(PuzzleGeneratedImageCandidateResponse { candidate_id, image_src: asset.image_src, @@ -1817,17 +2697,6 @@ async fn create_puzzle_text_to_image_generation( size: &str, candidate_count: u32, ) -> Result { - let mut parameters = Map::from_iter([ - ("n".to_string(), json!(candidate_count.clamp(1, 1))), - ("size".to_string(), Value::String(size.to_string())), - ("prompt_extend".to_string(), Value::Bool(true)), - ("watermark".to_string(), Value::Bool(false)), - ]); - parameters.insert( - "negative_prompt".to_string(), - Value::String(negative_prompt.to_string()), - ); - let response = http_client .post(format!( "{}/services/aigc/text2image/image-synthesis", @@ -1839,11 +2708,12 @@ async fn create_puzzle_text_to_image_generation( ) .header(reqwest::header::CONTENT_TYPE, "application/json") .header("X-DashScope-Async", "enable") - .json(&json!({ - "model": PUZZLE_TEXT_TO_IMAGE_MODEL, - "input": { "prompt": prompt }, - "parameters": parameters, - })) + .json(&build_puzzle_text_to_image_request_body( + prompt, + negative_prompt, + size, + candidate_count, + )) .send() .await .map_err(|error| { @@ -1855,6 +2725,7 @@ async fn create_puzzle_text_to_image_generation( })?; if !status.is_success() { return Err(map_puzzle_dashscope_upstream_error( + status, response_text.as_str(), "创建拼图图片生成任务失败", )); @@ -1886,6 +2757,7 @@ async fn create_puzzle_text_to_image_generation( })?; if !poll_status.is_success() { return Err(map_puzzle_dashscope_upstream_error( + poll_status, poll_text.as_str(), "查询拼图图片生成任务失败", )); @@ -1917,6 +2789,7 @@ async fn create_puzzle_text_to_image_generation( } if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") { return Err(map_puzzle_dashscope_upstream_error( + poll_status, poll_text.as_str(), "拼图图片生成任务失败", )); @@ -1932,6 +2805,33 @@ async fn create_puzzle_text_to_image_generation( ) } +fn build_puzzle_text_to_image_request_body( + prompt: &str, + negative_prompt: &str, + size: &str, + candidate_count: u32, +) -> Value { + let parameters = Map::from_iter([ + ("n".to_string(), json!(candidate_count.clamp(1, 1))), + ("size".to_string(), Value::String(size.to_string())), + ("prompt_extend".to_string(), Value::Bool(true)), + ("watermark".to_string(), Value::Bool(false)), + ]); + let mut input = Map::from_iter([("prompt".to_string(), Value::String(prompt.to_string()))]); + if !negative_prompt.trim().is_empty() { + input.insert( + "negative_prompt".to_string(), + Value::String(negative_prompt.trim().to_string()), + ); + } + + json!({ + "model": PUZZLE_TEXT_TO_IMAGE_MODEL, + "input": input, + "parameters": parameters, + }) +} + async fn resolve_puzzle_reference_image_as_data_url( state: &AppState, http_client: &reqwest::Client, @@ -2043,6 +2943,18 @@ async fn create_puzzle_image_to_image_generation( ) -> Result { let mut content = vec![json!({ "image": reference_image })]; content.push(json!({ "text": prompt })); + let mut parameters = Map::from_iter([ + ("n".to_string(), json!(candidate_count.clamp(1, 1))), + ("size".to_string(), Value::String(size.to_string())), + ("prompt_extend".to_string(), Value::Bool(true)), + ("watermark".to_string(), Value::Bool(false)), + ]); + if !negative_prompt.trim().is_empty() { + parameters.insert( + "negative_prompt".to_string(), + Value::String(negative_prompt.trim().to_string()), + ); + } let response = http_client .post(format!( @@ -2064,13 +2976,7 @@ async fn create_puzzle_image_to_image_generation( } ], }, - "parameters": { - "n": candidate_count.clamp(1, 1), - "size": size, - "negative_prompt": negative_prompt, - "prompt_extend": true, - "watermark": false, - }, + "parameters": parameters, })) .send() .await @@ -2083,6 +2989,7 @@ async fn create_puzzle_image_to_image_generation( })?; if !status.is_success() { return Err(map_puzzle_dashscope_upstream_error( + status, response_text.as_str(), "创建拼图参考图生成任务失败", )); @@ -2145,6 +3052,7 @@ async fn wait_puzzle_generated_images( })?; if !poll_status.is_success() { return Err(map_puzzle_dashscope_upstream_error( + poll_status, poll_text.as_str(), "查询拼图图片生成任务失败", )); @@ -2181,6 +3089,7 @@ async fn wait_puzzle_generated_images( } if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") { return Err(map_puzzle_dashscope_upstream_error( + poll_status, poll_text.as_str(), failure_message, )); @@ -2461,10 +3370,26 @@ fn map_puzzle_dashscope_request_error(message: String) -> AppError { })) } -fn map_puzzle_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError { +fn map_puzzle_dashscope_upstream_error( + upstream_status: reqwest::StatusCode, + raw_text: &str, + fallback_message: &str, +) -> AppError { + let message = parse_puzzle_api_error_message(raw_text, fallback_message); + let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800); + tracing::warn!( + provider = "dashscope", + upstream_status = upstream_status.as_u16(), + message = %message, + raw_excerpt = %raw_excerpt, + "拼图 DashScope 上游请求失败" + ); + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", - "message": parse_puzzle_api_error_message(raw_text, fallback_message), + "upstreamStatus": upstream_status.as_u16(), + "message": message, + "rawExcerpt": raw_excerpt, })) } @@ -2481,6 +3406,19 @@ fn parse_puzzle_api_error_message(raw_text: &str, fallback_message: &str) -> Str fallback_message.to_string() } +fn trim_puzzle_upstream_excerpt(raw_text: &str, max_chars: usize) -> String { + let normalized = raw_text.split_whitespace().collect::>().join(" "); + if normalized.chars().count() <= max_chars { + return normalized; + } + + let keep_chars = max_chars.saturating_sub(3); + format!( + "{}...", + normalized.chars().take(keep_chars).collect::() + ) +} + fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError { map_oss_error(error, "aliyun-oss") } diff --git a/server-rs/crates/api-server/src/puzzle_agent_turn.rs b/server-rs/crates/api-server/src/puzzle_agent_turn.rs index a1f7803f..1ddb6f0e 100644 --- a/server-rs/crates/api-server/src/puzzle_agent_turn.rs +++ b/server-rs/crates/api-server/src/puzzle_agent_turn.rs @@ -1,18 +1,16 @@ use module_puzzle::{PuzzleAgentStage, PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack}; use platform_llm::LlmClient; use serde::{Deserialize, Serialize}; -use serde_json::{Value as JsonValue, json}; -use spacetime_client::{ - PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentSessionRecord, -}; +use serde_json::Value as JsonValue; +use spacetime_client::{PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentSessionRecord}; -use crate::creation_agent_anchor_templates::{ - get_creation_agent_anchor_template, render_anchor_question_block, -}; -use crate::creation_agent_chat::render_quick_fill_extra_rules; use crate::creation_agent_llm_turn::{ CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn, }; +use crate::prompt::puzzle::agent_chat::{ + PUZZLE_AGENT_JSON_TURN_USER_PROMPT, PUZZLE_AGENT_SYSTEM_PROMPT, build_puzzle_agent_prompt, + serialize_puzzle_record_anchor_pack, +}; #[derive(Clone, Debug)] pub(crate) struct PuzzleAgentTurnRequest<'a> { @@ -60,63 +58,6 @@ struct PuzzleAgentModelOutput { next_anchor_pack: PuzzleAnchorPack, } -const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创拼图画面的中文创意策划。 - -你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。 - -你必须同时输出: -1. 一段直接发给用户的中文回复 replyText -2. 当前进度 progressPercent -3. 下一轮完整可用的 nextAnchorPack - -硬约束: -1. 只能输出 JSON,不能输出代码块或解释 -2. nextAnchorPack 必须是完整对象,不能只输出 patch -3. replyText 必须是自然中文,不能提“字段”“锚点”“结构”“JSON”等内部词 -4. replyText 一次最多推进一个最关键问题 -5. 如果用户已经给出明确方向,就优先吸收和收束,不要机械反问 -6. progressPercent 范围只能是 0 到 100 -7. status 只能使用 missing / inferred / confirmed / locked -"#; - -const PUZZLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字: -{ - "replyText": "", - "progressPercent": 0, - "nextAnchorPack": { - "themePromise": { - "key": "themePromise", - "label": "题材承诺", - "value": "", - "status": "missing" - }, - "visualSubject": { - "key": "visualSubject", - "label": "画面主体", - "value": "", - "status": "missing" - }, - "visualMood": { - "key": "visualMood", - "label": "视觉气质", - "value": "", - "status": "missing" - }, - "compositionHooks": { - "key": "compositionHooks", - "label": "拼图记忆点", - "value": "", - "status": "missing" - }, - "tagsAndForbidden": { - "key": "tagsAndForbidden", - "label": "标签与禁忌", - "value": "", - "status": "missing" - } - } -}"#; - pub(crate) async fn run_puzzle_agent_turn( request: PuzzleAgentTurnRequest<'_>, on_reply_update: F, @@ -128,7 +69,7 @@ where let turn_output = stream_creation_agent_json_turn( request.llm_client, format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}"), - "请按约定输出这一轮的 JSON。", + PUZZLE_AGENT_JSON_TURN_USER_PROMPT, request.enable_web_search, CreationAgentLlmTurnErrorMessages { model_unavailable: "当前模型不可用,请稍后重试。", @@ -185,10 +126,6 @@ pub(crate) fn build_failed_finalize_record_input( error_message: String, updated_at_micros: i64, ) -> PuzzleAgentMessageFinalizeRecordInput { - let anchor_pack_json = serde_json::to_string(&map_record_anchor_pack(&session.anchor_pack)) - .unwrap_or_else(|_| { - serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string()) - }); PuzzleAgentMessageFinalizeRecordInput { session_id, owner_user_id, @@ -196,61 +133,12 @@ pub(crate) fn build_failed_finalize_record_input( assistant_reply_text: None, stage: session.stage.clone(), progress_percent: session.progress_percent, - anchor_pack_json, + anchor_pack_json: serialize_puzzle_record_anchor_pack(&session.anchor_pack), error_message: Some(error_message), updated_at_micros, } } -fn build_puzzle_agent_prompt( - session: &PuzzleAgentSessionRecord, - quick_fill_requested: bool, -) -> String { - let anchor_question_block = get_creation_agent_anchor_template("puzzle") - .map(render_anchor_question_block) - .unwrap_or_else(|| "模板目标:收束成可以发布为拼图关卡的视觉方案。".to_string()); - let quick_fill_rules = if quick_fill_requested { - format!( - "\n\n{}", - render_quick_fill_extra_rules( - "当前题材方向里的拼图关键词", - "不要要求用户再提供素材、风格或禁忌", - "输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项", - "生成结果页", - ) - ) - } else { - String::new() - }; - format!( - "{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}", - anchor_question_block = anchor_question_block, - quick_fill_rules = quick_fill_rules, - turn = session.current_turn.saturating_add(1), - progress = session.progress_percent, - quick_fill_requested_text = if quick_fill_requested { "是" } else { "否" }, - anchor_pack = serde_json::to_string_pretty(&map_record_anchor_pack(&session.anchor_pack)) - .unwrap_or_else(|_| "{}".to_string()), - chat_history = - serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice())) - .unwrap_or_else(|_| "[]".to_string()), - contract = PUZZLE_AGENT_OUTPUT_CONTRACT, - ) -} - -fn build_chat_history(messages: &[PuzzleAgentMessageRecord]) -> Vec { - messages - .iter() - .map(|message| { - json!({ - "role": message.role, - "kind": message.kind, - "content": message.text, - }) - }) - .collect() -} - fn parse_model_output(parsed: &JsonValue) -> Result { let reply_text = parsed .get("replyText") @@ -348,27 +236,6 @@ fn resolve_puzzle_agent_stage(progress_percent: u32) -> PuzzleAgentStage { } } -fn map_record_anchor_pack(record: &spacetime_client::PuzzleAnchorPackRecord) -> PuzzleAnchorPack { - PuzzleAnchorPack { - theme_promise: map_record_anchor_item(&record.theme_promise), - visual_subject: map_record_anchor_item(&record.visual_subject), - visual_mood: map_record_anchor_item(&record.visual_mood), - composition_hooks: map_record_anchor_item(&record.composition_hooks), - tags_and_forbidden: map_record_anchor_item(&record.tags_and_forbidden), - } -} - -fn map_record_anchor_item( - record: &spacetime_client::PuzzleAnchorItemRecord, -) -> module_puzzle::PuzzleAnchorItem { - module_puzzle::PuzzleAnchorItem { - key: record.key.clone(), - label: record.label.clone(), - value: record.value.clone(), - status: parse_anchor_status(record.status.as_str()), - } -} - fn parse_anchor_status(value: &str) -> PuzzleAnchorStatus { match value { "confirmed" => PuzzleAnchorStatus::Confirmed, @@ -383,57 +250,9 @@ mod tests { use module_puzzle::PuzzleAnchorStatus; use serde_json::json; - use super::{build_puzzle_agent_prompt, parse_model_output}; + use super::parse_model_output; use crate::creation_agent_llm_turn::extract_reply_text_from_partial_json; - fn empty_session_record() -> spacetime_client::PuzzleAgentSessionRecord { - spacetime_client::PuzzleAgentSessionRecord { - session_id: "puzzle-session-test".to_string(), - current_turn: 2, - progress_percent: 60, - stage: "collecting_anchors".to_string(), - anchor_pack: spacetime_client::PuzzleAnchorPackRecord { - theme_promise: spacetime_client::PuzzleAnchorItemRecord { - key: "themePromise".to_string(), - label: "题材承诺".to_string(), - value: "雨夜猫咪遗迹".to_string(), - status: "confirmed".to_string(), - }, - visual_subject: spacetime_client::PuzzleAnchorItemRecord { - key: "visualSubject".to_string(), - label: "画面主体".to_string(), - value: String::new(), - status: "missing".to_string(), - }, - visual_mood: spacetime_client::PuzzleAnchorItemRecord { - key: "visualMood".to_string(), - label: "视觉气质".to_string(), - value: String::new(), - status: "missing".to_string(), - }, - composition_hooks: spacetime_client::PuzzleAnchorItemRecord { - key: "compositionHooks".to_string(), - label: "拼图记忆点".to_string(), - value: String::new(), - status: "missing".to_string(), - }, - tags_and_forbidden: spacetime_client::PuzzleAnchorItemRecord { - key: "tagsAndForbidden".to_string(), - label: "标签与禁忌".to_string(), - value: String::new(), - status: "missing".to_string(), - }, - }, - draft: None, - messages: Vec::new(), - last_assistant_reply: None, - published_profile_id: None, - suggested_actions: Vec::new(), - result_preview: None, - updated_at: "2026-04-24T10:00:00.000Z".to_string(), - } - } - #[test] fn extract_reply_text_from_partial_json_preserves_chinese_characters() { let partial_json = r#"{"replyText":"夜雨猫咪遗迹","progressPercent":42"#; @@ -498,13 +317,4 @@ mod tests { "雨夜、猫咪、神庙遗迹;禁止文字水印" ); } - - #[test] - fn quick_fill_prompt_forbids_follow_up_questions() { - let prompt = build_puzzle_agent_prompt(&empty_session_record(), true); - - assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字")); - assert!(prompt.contains("不要再继续提问")); - assert!(prompt.contains("progressPercent 直接输出为 100")); - } } diff --git a/server-rs/crates/api-server/src/registration_reward.rs b/server-rs/crates/api-server/src/registration_reward.rs new file mode 100644 index 00000000..d9aeca6e --- /dev/null +++ b/server-rs/crates/api-server/src/registration_reward.rs @@ -0,0 +1,30 @@ +#[cfg(not(test))] +use tracing::warn; + +use crate::{request_context::RequestContext, state::AppState}; + +pub async fn grant_new_user_registration_wallet_reward( + state: &AppState, + request_context: &RequestContext, + user_id: &str, +) { + #[cfg(test)] + { + let _ = (state, request_context, user_id); + } + + #[cfg(not(test))] + if let Err(error) = state + .spacetime_client() + .grant_new_user_registration_wallet_reward(user_id.to_string()) + .await + { + warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + user_id = user_id, + error = %error, + "新用户注册光点赠送失败,注册流程继续" + ); + } +} diff --git a/server-rs/crates/api-server/src/runtime_chat.rs b/server-rs/crates/api-server/src/runtime_chat.rs index 845a9dd0..0536fd91 100644 --- a/server-rs/crates/api-server/src/runtime_chat.rs +++ b/server-rs/crates/api-server/src/runtime_chat.rs @@ -10,6 +10,7 @@ use axum::{ use platform_llm::{LlmMessage, LlmTextRequest}; use serde::Deserialize; use serde_json::{Value, json}; +use shared_contracts::runtime_story::RuntimeStorySnapshotPayload; use std::convert::Infallible; use module_runtime_story::{ @@ -21,12 +22,13 @@ use module_runtime_story::{ use crate::{ auth::AuthenticatedAccessToken, http_error::AppError, + llm_model_routing::RPG_STORY_LLM_MODEL, prompt::runtime_chat::{ NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT, NpcChatTurnPromptInput, build_deterministic_chat_suggestions, - build_deterministic_npc_reply, build_fallback_function_suggestions, - build_fallback_npc_chat_suggestions, build_npc_chat_turn_reply_prompt, - build_npc_chat_turn_suggestion_prompt, + build_deterministic_hostile_breakoff_reply, build_deterministic_npc_reply, + build_fallback_function_suggestions, build_fallback_npc_chat_suggestions, + build_npc_chat_turn_reply_prompt, build_npc_chat_turn_suggestion_prompt, }, request_context::RequestContext, state::AppState, @@ -38,6 +40,8 @@ pub struct NpcChatTurnRequest { #[serde(default)] session_id: Option, #[serde(default)] + snapshot: Option, + #[serde(default)] world_type: String, #[serde(default)] character: Option, @@ -133,16 +137,26 @@ pub async fn stream_runtime_npc_chat_turn( let (npc_reply, suggestions, function_suggestions, force_exit) = match llm_result { Some(result) => result, None => { - let npc_reply = build_deterministic_npc_reply( - npc_name.as_str(), - player_message.as_str(), - payload.npc_initiates_conversation, - ); - let force_exit = should_force_chat_exit(payload.chat_directive.as_ref()) - || should_hostile_chat_breakoff_deterministically( + let deterministic_hostile_breakoff = + should_hostile_chat_breakoff_deterministically( player_message.as_str(), payload.chat_directive.as_ref(), + Some(&payload.npc_state), ); + let force_exit = should_force_chat_exit(payload.chat_directive.as_ref()) + || deterministic_hostile_breakoff; + let npc_reply = if deterministic_hostile_breakoff { + build_deterministic_hostile_breakoff_reply( + npc_name.as_str(), + player_message.as_str(), + ) + } else { + build_deterministic_npc_reply( + npc_name.as_str(), + player_message.as_str(), + payload.npc_initiates_conversation, + ) + }; let suggestions = if force_exit { Vec::new() } else { @@ -224,6 +238,7 @@ where ]); reply_request.max_tokens = Some(700); reply_request.enable_web_search = state.config.rpg_llm_web_search_enabled; + reply_request.model = Some(RPG_STORY_LLM_MODEL.to_string()); let reply_response = llm_client .stream_text(reply_request, |delta| { @@ -251,6 +266,7 @@ where ]); suggestion_request.max_tokens = Some(200); suggestion_request.enable_web_search = state.config.rpg_llm_web_search_enabled; + suggestion_request.model = Some(RPG_STORY_LLM_MODEL.to_string()); let suggestion_text = llm_client .request_text(suggestion_request) .await @@ -266,6 +282,7 @@ where || should_hostile_chat_breakoff_deterministically( payload.player_message.as_str(), payload.chat_directive.as_ref(), + Some(&payload.npc_state), ); if force_exit { @@ -292,6 +309,16 @@ async fn hydrate_npc_chat_turn_request_from_session( // 中文注释:旧调用没有 sessionId 时继续使用请求体字段;正式运行态由后端快照投影上下文。 return Ok(()); }; + + if let Some(game_state) = resolve_request_snapshot_game_state( + request_context, + session_id.as_str(), + payload.snapshot.as_ref(), + )? { + apply_npc_chat_turn_game_state(payload, game_state); + return Ok(()); + } + let record = state .get_runtime_snapshot_record(user_id) .await @@ -328,6 +355,49 @@ async fn hydrate_npc_chat_turn_request_from_session( )); } + apply_npc_chat_turn_game_state(payload, game_state); + + Ok(()) +} + +fn resolve_request_snapshot_game_state( + request_context: &RequestContext, + session_id: &str, + snapshot: Option<&RuntimeStorySnapshotPayload>, +) -> Result, Response> { + let Some(snapshot) = snapshot else { + return Ok(None); + }; + if !snapshot.game_state.is_object() { + return Err(runtime_chat_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-chat", + "field": "snapshot.gameState", + "message": "snapshot.gameState 必须是 JSON object", + })), + )); + } + + let snapshot_session_id = + read_runtime_session_id(&snapshot.game_state).unwrap_or_else(|| session_id.to_string()); + if snapshot_session_id != session_id { + return Err(runtime_chat_error_response( + request_context, + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "runtime-chat", + "message": "请求的运行时会话与服务端快照不一致,请重新进入游戏", + "sessionId": session_id, + "snapshotSessionId": snapshot_session_id, + })), + )); + } + + // 中文注释:预览/测试/禁存运行态只把请求 snapshot 用于本轮 prompt 投影,不写入正式存档。 + Ok(Some(snapshot.game_state.clone())) +} + +fn apply_npc_chat_turn_game_state(payload: &mut NpcChatTurnRequest, game_state: Value) { payload.world_type = current_world_type(&game_state).unwrap_or_default(); payload.character = read_field(&game_state, "playerCharacter").cloned(); payload.player = payload.character.clone(); @@ -361,8 +431,6 @@ async fn hydrate_npc_chat_turn_request_from_session( object.insert("state".to_string(), game_state); } } - - Ok(()) } fn resolve_current_request_npc_state(game_state: &Value) -> Option { @@ -561,6 +629,7 @@ fn is_hostile_model_chat(chat_directive: Option<&Value>) -> bool { fn should_hostile_chat_breakoff_deterministically( player_message: &str, chat_directive: Option<&Value>, + npc_state: Option<&Value>, ) -> bool { if !is_hostile_model_chat(chat_directive) { return false; @@ -574,6 +643,14 @@ fn should_hostile_chat_breakoff_deterministically( return true; } + // 中文注释:模型建议不可用时,后端兜底仍按敌对聊天口径避免负面挑衅被拖成闲聊。 + if npc_state + .and_then(|state| read_number_field(state, "chattedCount")) + .is_some_and(|chatted_count| chatted_count >= 4.0) + { + return true; + } + let hostile_break_words = [ "动手", "开战", @@ -583,6 +660,18 @@ fn should_hostile_chat_breakoff_deterministically( "闭嘴", "少废话", "别挡路", + "废话", + "威胁", + "找死", + "送死", + "住口", + "让开", + "滚开", + "不退", + "不会退", + "别装", + "骗子", + "叛徒", ]; count_keyword_matches(player_message, &hostile_break_words) > 0 } @@ -709,6 +798,8 @@ fn runtime_chat_error_response(request_context: &RequestContext, error: AppError #[cfg(test)] mod tests { use super::*; + use crate::{config::AppConfig, request_context::RequestContext, state::AppState}; + use std::time::Duration; #[test] fn npc_chat_affinity_delta_keeps_node_keyword_rules() { @@ -752,4 +843,174 @@ mod tests { vec!["继续问线索", "表明立场", "拉近关系"] ); } + + #[test] + fn hostile_chat_breakoff_fallback_triggers_on_negative_words() { + let chat_directive = json!({ + "terminationMode": "hostile_model", + "isHostileChat": true, + }); + let npc_state = json!({ "chattedCount": 1 }); + + assert!(should_hostile_chat_breakoff_deterministically( + "少废话,让开,不然现在就动手。", + Some(&chat_directive), + Some(&npc_state), + )); + } + + #[test] + fn hostile_chat_breakoff_fallback_triggers_after_four_turns() { + let chat_directive = json!({ + "terminationMode": "hostile_model", + "isHostileChat": true, + }); + let npc_state = json!({ "chattedCount": 4 }); + + assert!(should_hostile_chat_breakoff_deterministically( + "我还想再问一个问题。", + Some(&chat_directive), + Some(&npc_state), + )); + } + + #[test] + fn hostile_chat_breakoff_fallback_ignores_non_hostile_chat() { + let chat_directive = json!({ + "terminationMode": "none", + "isHostileChat": false, + }); + let npc_state = json!({ "chattedCount": 6 }); + + assert!(!should_hostile_chat_breakoff_deterministically( + "少废话,让开。", + Some(&chat_directive), + Some(&npc_state), + )); + } + + #[tokio::test] + async fn npc_chat_turn_prefers_request_snapshot_over_persisted_session() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + state + .put_runtime_snapshot_record( + "user_00000001".to_string(), + 1, + "adventure".to_string(), + json!({ + "worldType": "WUXIA", + "runtimeSessionId": "runtime-main", + "playerCharacter": { "id": "hero-main", "name": "旧存档" }, + "currentEncounter": { "id": "npc-main", "npcName": "旧 NPC" }, + "sceneHostileNpcs": [], + "storyHistory": [], + }), + None, + 1, + ) + .await + .expect("snapshot should seed"); + let request_context = test_request_context(); + let mut payload = test_npc_chat_turn_payload( + "runtime-preview", + Some(json!({ + "worldType": "CUSTOM", + "runtimeSessionId": "runtime-preview", + "runtimePersistenceDisabled": true, + "playerCharacter": { "id": "hero-preview", "name": "临时角色" }, + "currentEncounter": { "id": "npc-preview", "npcName": "临时 NPC" }, + "sceneHostileNpcs": [{ "id": "monster-preview", "name": "雾影" }], + "storyHistory": [{ "text": "临时故事" }], + "npcStates": { + "npc-preview": { + "affinity": 12, + "helpUsed": false, + "chattedCount": 2, + "giftsGiven": 0, + "recruited": false + } + } + })), + ); + + hydrate_npc_chat_turn_request_from_session( + &state, + &request_context, + "user_00000001".to_string(), + &mut payload, + ) + .await + .expect("request snapshot should hydrate"); + + assert_eq!(payload.world_type, "CUSTOM"); + assert_eq!( + read_optional_string_field(&payload.encounter, "npcName").as_deref(), + Some("临时 NPC") + ); + assert_eq!(payload.monsters.len(), 1); + assert_eq!(read_i32_field(&payload.npc_state, "affinity"), Some(12)); + } + + #[tokio::test] + async fn npc_chat_turn_rejects_request_snapshot_session_mismatch() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let request_context = test_request_context(); + let mut payload = test_npc_chat_turn_payload( + "runtime-preview", + Some(json!({ + "worldType": "WUXIA", + "runtimeSessionId": "runtime-other", + })), + ); + + let response = hydrate_npc_chat_turn_request_from_session( + &state, + &request_context, + "user_00000001".to_string(), + &mut payload, + ) + .await + .expect_err("snapshot session mismatch should fail"); + + assert_eq!(response.status(), StatusCode::CONFLICT); + } + + fn test_request_context() -> RequestContext { + RequestContext::new( + "runtime-chat-test".to_string(), + "POST /api/runtime/chat/npc/turn/stream".to_string(), + Duration::ZERO, + false, + ) + } + + fn test_npc_chat_turn_payload( + session_id: &str, + game_state: Option, + ) -> NpcChatTurnRequest { + NpcChatTurnRequest { + session_id: Some(session_id.to_string()), + snapshot: game_state.map(|game_state| RuntimeStorySnapshotPayload { + saved_at: None, + bottom_tab: "adventure".to_string(), + game_state, + current_story: None, + }), + world_type: String::new(), + character: None, + player: None, + encounter: json!({ "id": "npc-request", "npcName": "请求 NPC" }), + monsters: Vec::new(), + history: Vec::new(), + context: Value::Null, + conversation_history: Vec::new(), + dialogue: Vec::new(), + combat_context: None, + player_message: "你刚才看见了什么?".to_string(), + npc_state: Value::Null, + npc_initiates_conversation: false, + quest_offer_context: None, + chat_directive: None, + } + } } diff --git a/server-rs/crates/api-server/src/runtime_chat_plain.rs b/server-rs/crates/api-server/src/runtime_chat_plain.rs index 6d29cb88..8e1b17e8 100644 --- a/server-rs/crates/api-server/src/runtime_chat_plain.rs +++ b/server-rs/crates/api-server/src/runtime_chat_plain.rs @@ -10,11 +10,13 @@ use axum::{ use platform_llm::{LlmMessage, LlmTextRequest}; use serde::Deserialize; use serde_json::{Value, json}; +use shared_contracts::runtime_story::RuntimeStorySnapshotPayload; use std::convert::Infallible; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, - prompt::runtime_chat::*, request_context::RequestContext, state::AppState, + llm_model_routing::RPG_STORY_LLM_MODEL, prompt::runtime_chat::*, + request_context::RequestContext, state::AppState, }; use module_runtime_story::{ RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type, @@ -27,6 +29,8 @@ pub struct RuntimeCharacterChatRequest { #[serde(default)] session_id: Option, #[serde(default)] + snapshot: Option, + #[serde(default)] world_type: String, #[serde(default)] player_character: Value, @@ -54,6 +58,8 @@ pub struct RuntimeNpcDialogueRequest { #[serde(default)] session_id: Option, #[serde(default)] + snapshot: Option, + #[serde(default)] world_type: String, #[serde(default)] character: Value, @@ -77,6 +83,8 @@ pub struct RuntimeNpcRecruitDialogueRequest { #[serde(default)] session_id: Option, #[serde(default)] + snapshot: Option, + #[serde(default)] world_type: String, #[serde(default)] character: Value, @@ -346,6 +354,7 @@ async fn hydrate_character_chat_request_from_session( request_context, user_id, payload.session_id.as_deref(), + payload.snapshot.as_ref(), ) .await? else { @@ -382,6 +391,7 @@ async fn hydrate_npc_dialogue_request_from_session( request_context, user_id, payload.session_id.as_deref(), + payload.snapshot.as_ref(), ) .await? else { @@ -430,6 +440,7 @@ async fn hydrate_npc_recruit_request_from_session( request_context, user_id, payload.session_id.as_deref(), + payload.snapshot.as_ref(), ) .await? else { @@ -472,11 +483,19 @@ async fn resolve_runtime_chat_game_state( request_context: &RequestContext, user_id: String, session_id: Option<&str>, + snapshot: Option<&RuntimeStorySnapshotPayload>, ) -> Result, Response> { let Some(session_id) = session_id.and_then(normalize_required_string) else { // 中文注释:未携带 sessionId 的旧调用仅保留兼容,后续正式运行态应全部走后端快照。 return Ok(None); }; + + if let Some(game_state) = + resolve_request_snapshot_game_state(request_context, session_id.as_str(), snapshot)? + { + return Ok(Some(game_state)); + } + let record = state .get_runtime_snapshot_record(user_id) .await @@ -516,6 +535,43 @@ async fn resolve_runtime_chat_game_state( Ok(Some(game_state)) } +fn resolve_request_snapshot_game_state( + request_context: &RequestContext, + session_id: &str, + snapshot: Option<&RuntimeStorySnapshotPayload>, +) -> Result, Response> { + let Some(snapshot) = snapshot else { + return Ok(None); + }; + if !snapshot.game_state.is_object() { + return Err(runtime_plain_chat_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "runtime-chat", + "field": "snapshot.gameState", + "message": "snapshot.gameState 必须是 JSON object", + })), + )); + } + + let snapshot_session_id = + read_runtime_session_id(&snapshot.game_state).unwrap_or_else(|| session_id.to_string()); + if snapshot_session_id != session_id { + return Err(runtime_plain_chat_error_response( + request_context, + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "runtime-chat", + "message": "请求的运行时会话与服务端快照不一致,请重新进入游戏", + "sessionId": session_id, + "snapshotSessionId": snapshot_session_id, + })), + )); + } + + // 中文注释:临时运行态聊天只读取请求 snapshot 构造上下文,不把它写回 runtime_snapshot。 + Ok(Some(snapshot.game_state.clone())) +} + async fn request_runtime_plain_text( state: &AppState, system_prompt: &'static str, @@ -532,6 +588,7 @@ async fn request_runtime_plain_text( ]); request.max_tokens = Some(400); request.enable_web_search = state.config.rpg_llm_web_search_enabled; + request.model = Some(RPG_STORY_LLM_MODEL.to_string()); llm_client .request_text(request) @@ -563,6 +620,7 @@ fn stream_plain_text_response<'a>( ]); request.max_tokens = Some(700); request.enable_web_search = enable_web_search; + request.model = Some(RPG_STORY_LLM_MODEL.to_string()); let response = llm_client .stream_text(request, |_| {}) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 67fe93d7..2093c500 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -5,31 +5,33 @@ use axum::{ response::Response, }; use module_runtime::{ - PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord, - RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, - RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, - RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, - RuntimeReferralRedeemRecord, + PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileInviteCodeRecord, + RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord, + RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, + RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileWalletLedgerSourceType, + RuntimeReferralInviteCenterRecord, }; use serde_json::{Value, json}; use shared_contracts::runtime::{ - AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest, - CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, - PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, + AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileInviteCodeRequest, + AdminUpsertProfileRedeemCodeRequest, CreateProfileRechargeOrderRequest, + CreateProfileRechargeOrderResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM, PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, - ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, - ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, - ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse, - ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse, - ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest, - RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest, - RedeemProfileRewardCodeResponse, + ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, + ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, + ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse, + ProfileReferralInviteCenterResponse, ProfileReferralInvitedUserResponse, + ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, + RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse, + RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, }; use spacetime_client::SpacetimeClientError; use time::OffsetDateTime; @@ -109,6 +111,9 @@ fn format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::SnapshotSync => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC } + RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD + } RuntimeProfileWalletLedgerSourceType::InviteInviterReward => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD } @@ -127,6 +132,9 @@ fn format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD } + RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM + } } } @@ -330,6 +338,37 @@ pub async fn admin_disable_profile_redeem_code( )) } +pub async fn admin_upsert_profile_invite_code( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, Response> { + let metadata_json = normalize_admin_invite_code_metadata(payload.metadata) + .map_err(|error| runtime_profile_error_response(&request_context, error))?; + let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let record = state + .spacetime_client() + .admin_upsert_profile_invite_code( + admin.session().username.clone(), + payload.invite_code, + metadata_json, + updated_at_micros as i64, + ) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_profile_invite_code_admin_response(record), + )) +} + pub async fn get_profile_play_stats( State(state): State, Extension(request_context): Extension, @@ -479,6 +518,16 @@ fn build_profile_referral_invite_center_response( today_inviter_reward_count: record.today_inviter_reward_count, today_inviter_reward_remaining: record.today_inviter_reward_remaining, reward_points: record.reward_points, + invited_users: record + .invited_users + .into_iter() + .map(|user| ProfileReferralInvitedUserResponse { + user_id: user.user_id, + display_name: user.display_name, + avatar_url: user.avatar_url, + bound_at: user.bound_at, + }) + .collect(), has_redeemed_code: record.has_redeemed_code, bound_inviter_user_id: record.bound_inviter_user_id, bound_at: record.bound_at, @@ -487,7 +536,7 @@ fn build_profile_referral_invite_center_response( } fn build_redeem_profile_referral_invite_code_response( - record: RuntimeReferralRedeemRecord, + record: module_runtime::RuntimeReferralRedeemRecord, ) -> RedeemProfileReferralInviteCodeResponse { RedeemProfileReferralInviteCodeResponse { center: build_profile_referral_invite_center_response(record.center), @@ -515,6 +564,30 @@ fn build_redeem_profile_reward_code_response( } } +fn normalize_admin_invite_code_metadata(metadata: Option) -> Result { + let metadata = match metadata { + Some(Value::Null) | None => json!({}), + Some(value) if value.is_object() => value, + Some(_) => { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("邀请码 metadata 必须是 JSON 对象") + .with_details(json!({ "field": "metadata" }))); + } + }; + let metadata_json = serde_json::to_string(&metadata).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message(format!("邀请码 metadata 序列化失败:{error}")) + .with_details(json!({ "field": "metadata" })) + })?; + if metadata_json.len() > 4096 { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("邀请码 metadata 不能超过 4096 bytes") + .with_details(json!({ "field": "metadata" }))); + } + + Ok(metadata_json) +} + fn parse_profile_redeem_code_mode(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { "public" => Ok(RuntimeProfileRedeemCodeMode::Public), @@ -524,6 +597,20 @@ fn parse_profile_redeem_code_mode(raw: &str) -> Result ProfileInviteCodeAdminResponse { + let metadata = + serde_json::from_str::(&record.metadata_json).unwrap_or_else(|_| json!({})); + ProfileInviteCodeAdminResponse { + user_id: record.user_id, + invite_code: record.invite_code, + metadata, + created_at: record.created_at, + updated_at: record.updated_at, + } +} + fn build_profile_redeem_code_admin_response( record: RuntimeProfileRedeemCodeRecord, ) -> ProfileRedeemCodeAdminResponse { @@ -545,18 +632,31 @@ fn build_profile_redeem_code_admin_response( mod tests { use module_runtime::RuntimeProfileWalletLedgerSourceType; - use super::format_profile_wallet_ledger_source_type; + use super::{format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata}; use axum::{ body::Body, http::{Request, StatusCode}, }; + use http_body_util::BodyExt; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + }; + use serde_json::Value; + use std::time::Duration; + use time::OffsetDateTime; use tower::ServiceExt; use crate::{app::build_router, config::AppConfig, state::AppState}; #[test] - fn profile_wallet_ledger_source_type_formats_asset_operation_values() { + fn profile_wallet_ledger_source_type_formats_backend_values() { + assert_eq!( + format_profile_wallet_ledger_source_type( + RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward + ), + shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD + ); assert_eq!( format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::AssetOperationConsume @@ -569,6 +669,12 @@ mod tests { ), shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND ); + assert_eq!( + format_profile_wallet_ledger_source_type( + RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim + ), + shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM + ); } #[tokio::test] @@ -699,6 +805,60 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn profile_referral_redeem_code_calls_spacetime_for_authenticated_user() { + let state = seed_authenticated_state().await; + let token = issue_access_token(&state); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/profile/referrals/redeem-code") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(r#"{"inviteCode":"SY12345678"}"#)) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) + ); + } + + #[test] + fn admin_invite_code_metadata_accepts_only_json_object() { + assert_eq!( + normalize_admin_invite_code_metadata(None).expect("empty metadata should default"), + "{}" + ); + assert_eq!( + normalize_admin_invite_code_metadata(Some(serde_json::json!({ + "channel": "spring", + "source": "banner" + }))) + .expect("object metadata should serialize"), + r#"{"channel":"spring","source":"banner"}"# + ); + + let error = normalize_admin_invite_code_metadata(Some(serde_json::json!("spring"))) + .expect_err("non-object metadata should reject"); + assert_eq!(error.message(), "邀请码 metadata 必须是 JSON 对象"); + } + #[tokio::test] async fn runtime_profile_legacy_routes_are_not_mounted() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -731,4 +891,40 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); } } + + async fn seed_authenticated_state() -> AppState { + let state = AppState::new(fast_spacetime_timeout_config()).expect("state should build"); + state + .seed_test_phone_user_with_password("13800138104", "secret123") + .await + .id; + state + } + + fn fast_spacetime_timeout_config() -> AppConfig { + AppConfig { + spacetime_procedure_timeout: Duration::from_secs(1), + ..AppConfig::default() + } + } + + fn issue_access_token(state: &AppState) -> String { + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: "user_00000001".to_string(), + session_id: "sess_runtime_profile".to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 2, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("资料页用户".to_string()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } } diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 232ecb3a..19cc3d15 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -164,6 +164,7 @@ impl AppState { database: config.spacetime_database.clone(), token: config.spacetime_token.clone(), pool_size: config.spacetime_pool_size, + procedure_timeout: config.spacetime_procedure_timeout, }); let llm_client = build_llm_client(&config)?; @@ -223,13 +224,29 @@ impl AppState { OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000, ) .map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?; + // 本地 auth_store 是当前认证请求的即时真相源;SpacetimeDB 快照用于跨进程恢复。 + // 远端数据库挂起或网络异常时,只降级远端恢复能力,不能让已成功的登录/刷新/退出回滚为失败。 #[cfg(not(test))] - self.spacetime_client + if let Err(error) = self + .spacetime_client .upsert_auth_store_snapshot(snapshot_json, updated_at_micros) - .await?; - // 写入 SpacetimeDB 后立刻回读一次,确保内存快照与表真相对齐。 + .await + { + warn!( + error = %error, + "认证快照写入 SpacetimeDB 失败,当前认证流程继续" + ); + return Ok(()); + } + // 写入快照后尝试拆入正式认证表;失败只影响远端表恢复,不阻断当前认证响应。 #[cfg(not(test))] - self.spacetime_client.import_auth_store_snapshot().await?; + if let Err(error) = self.spacetime_client.import_auth_store_snapshot().await { + warn!( + error = %error, + "认证快照导入 SpacetimeDB 正式表失败,当前认证流程继续" + ); + return Ok(()); + } #[cfg(not(test))] Ok(()) } @@ -242,6 +259,7 @@ impl AppState { database: config.spacetime_database.clone(), token: config.spacetime_token.clone(), pool_size: config.spacetime_pool_size, + procedure_timeout: config.spacetime_procedure_timeout, }); match spacetime_client .export_auth_store_snapshot_from_tables() diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index 05f43da5..3725caae 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -193,6 +193,14 @@ pub async fn bind_wechat_phone( ) .await .map_err(map_wechat_bind_phone_error)?; + if result.activated_new_user { + crate::registration_reward::grant_new_user_registration_wallet_reward( + &state, + &request_context, + &result.user.id, + ) + .await; + } let session_client = resolve_session_client_context(&headers); let signed_session = create_auth_session( &state, diff --git a/server-rs/crates/api-server/src/work_author.rs b/server-rs/crates/api-server/src/work_author.rs new file mode 100644 index 00000000..e45ebfdd --- /dev/null +++ b/server-rs/crates/api-server/src/work_author.rs @@ -0,0 +1,54 @@ +use module_auth::AuthUser; + +use crate::state::AppState; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WorkAuthorSummary { + pub display_name: String, + pub public_user_code: Option, +} + +/// 中文注释:作品作者的真相源是 owner_user_id;历史昵称字段只作为账号资料不可读时的兼容回退。 +pub fn resolve_work_author_by_user_id( + state: &AppState, + owner_user_id: &str, + fallback_display_name: Option<&str>, + fallback_public_user_code: Option<&str>, +) -> WorkAuthorSummary { + let fallback_display_name = + normalize_optional_text(fallback_display_name).unwrap_or_else(|| "玩家".to_string()); + let fallback_public_user_code = normalize_optional_text(fallback_public_user_code); + + let Some(owner_user_id) = normalize_optional_text(Some(owner_user_id)) else { + return WorkAuthorSummary { + display_name: fallback_display_name, + public_user_code: fallback_public_user_code, + }; + }; + + match state.auth_user_service().get_user_by_id(&owner_user_id) { + Ok(Some(user)) => map_auth_user_to_work_author_summary(user, fallback_display_name), + Ok(None) | Err(_) => WorkAuthorSummary { + display_name: fallback_display_name, + public_user_code: fallback_public_user_code, + }, + } +} + +fn map_auth_user_to_work_author_summary( + user: AuthUser, + fallback_display_name: String, +) -> WorkAuthorSummary { + WorkAuthorSummary { + display_name: normalize_optional_text(Some(user.display_name.as_str())) + .unwrap_or(fallback_display_name), + public_user_code: normalize_optional_text(Some(user.public_user_code.as_str())), + } +} + +fn normalize_optional_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} diff --git a/server-rs/crates/module-ai/Cargo.toml b/server-rs/crates/module-ai/Cargo.toml index c3700c0e..fb8516e5 100644 --- a/server-rs/crates/module-ai/Cargo.toml +++ b/server-rs/crates/module-ai/Cargo.toml @@ -10,6 +10,5 @@ spacetime-types = ["dep:spacetimedb"] [dependencies] serde = { version = "1", features = ["derive"] } -serde_json = "1" shared-kernel = { path = "../shared-kernel" } spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-auth/Cargo.toml b/server-rs/crates/module-auth/Cargo.toml index 48ca1bd7..b5749c82 100644 --- a/server-rs/crates/module-auth/Cargo.toml +++ b/server-rs/crates/module-auth/Cargo.toml @@ -11,7 +11,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" time = { version = "0.3", features = ["formatting", "parsing"] } tracing = "0.1" -uuid = { version = "1", features = ["v4"] } [dev-dependencies] tokio = { version = "1", features = ["macros", "rt"] } diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index b5fb09e9..a90ad867 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -1,4 +1,4 @@ -mod application; +mod application; mod commands; mod domain; mod errors; diff --git a/server-rs/crates/module-big-fish/src/application.rs b/server-rs/crates/module-big-fish/src/application.rs index 4b6cc670..413eb174 100644 --- a/server-rs/crates/module-big-fish/src/application.rs +++ b/server-rs/crates/module-big-fish/src/application.rs @@ -9,8 +9,9 @@ use crate::{ BigFishAssetGenerateInput, BigFishDraftCompileInput, BigFishInputSubmitInput, BigFishMessageFinalizeInput, BigFishMessageSubmitInput, BigFishPlayRecordInput, BigFishPublishInput, BigFishRunGetInput, BigFishRunStartInput, BigFishSessionCreateInput, - BigFishSessionGetInput, BigFishWorksListInput, EvaluateBigFishPublishReadinessCommand, - StartBigFishRunCommand, SubmitBigFishInputCommand, + BigFishSessionGetInput, BigFishWorkLikeRecordInput, BigFishWorkRemixInput, + BigFishWorksListInput, EvaluateBigFishPublishReadinessCommand, StartBigFishRunCommand, + SubmitBigFishInputCommand, }, domain::{ BIG_FISH_ASSET_SLOT_ID_PREFIX, BIG_FISH_DEFAULT_LEVEL_COUNT, @@ -859,6 +860,33 @@ pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), Ok(()) } +pub fn validate_like_record_input( + input: &BigFishWorkLikeRecordInput, +) -> Result<(), BigFishFieldError> { + if normalize_required_string(&input.session_id).is_none() { + return Err(BigFishFieldError::MissingSessionId); + } + if normalize_required_string(&input.user_id).is_none() { + return Err(BigFishFieldError::MissingOwnerUserId); + } + Ok(()) +} + +pub fn validate_work_remix_input(input: &BigFishWorkRemixInput) -> Result<(), BigFishFieldError> { + if normalize_required_string(&input.source_session_id).is_none() + || normalize_required_string(&input.target_session_id).is_none() + { + return Err(BigFishFieldError::MissingSessionId); + } + if normalize_required_string(&input.target_owner_user_id).is_none() { + return Err(BigFishFieldError::MissingOwnerUserId); + } + if normalize_required_string(&input.welcome_message_id).is_none() { + return Err(BigFishFieldError::MissingMessageId); + } + Ok(()) +} + pub fn validate_run_start_input(input: &BigFishRunStartInput) -> Result<(), BigFishFieldError> { validate_session_owner(&input.session_id, &input.owner_user_id)?; if normalize_required_string(&input.run_id).is_none() { diff --git a/server-rs/crates/module-big-fish/src/commands.rs b/server-rs/crates/module-big-fish/src/commands.rs index 7a381e0e..72d67bdd 100644 --- a/server-rs/crates/module-big-fish/src/commands.rs +++ b/server-rs/crates/module-big-fish/src/commands.rs @@ -54,6 +54,16 @@ pub struct BigFishWorkDeleteInput { pub owner_user_id: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BigFishWorkRemixInput { + pub source_session_id: String, + pub target_session_id: String, + pub target_owner_user_id: String, + pub welcome_message_id: String, + pub remixed_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishWorksProcedureResult { @@ -143,6 +153,14 @@ pub struct BigFishPlayRecordInput { pub played_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BigFishWorkLikeRecordInput { + pub session_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishRunStartInput { diff --git a/server-rs/crates/module-big-fish/src/domain.rs b/server-rs/crates/module-big-fish/src/domain.rs index 298292b9..ce956d20 100644 --- a/server-rs/crates/module-big-fish/src/domain.rs +++ b/server-rs/crates/module-big-fish/src/domain.rs @@ -10,6 +10,7 @@ pub const BIG_FISH_SESSION_ID_PREFIX: &str = "big-fish-session-"; pub const BIG_FISH_MESSAGE_ID_PREFIX: &str = "big-fish-message-"; pub const BIG_FISH_OPERATION_ID_PREFIX: &str = "big-fish-operation-"; pub const BIG_FISH_ASSET_SLOT_ID_PREFIX: &str = "big-fish-asset-"; +pub const PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID: &str = "public-big-fish-gallery"; pub const BIG_FISH_DEFAULT_LEVEL_COUNT: u32 = 8; pub const BIG_FISH_MIN_LEVEL_COUNT: u32 = 6; pub const BIG_FISH_MAX_LEVEL_COUNT: u32 = 12; @@ -227,6 +228,11 @@ pub struct BigFishWorkSummarySnapshot { pub level_motion_ready_count: u32, pub background_ready: bool, pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + #[serde(default)] + pub recent_play_count_7d: u32, + pub published_at_micros: Option, } /// 发布门禁的领域判定结果。 diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index ebf05717..6acdc7c7 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -9,47 +9,3 @@ pub use commands::*; pub use domain::*; pub use errors::*; pub use events::*; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_draft_compiles_eight_levels_with_fixed_runtime_params() { - let draft = compile_default_draft(&infer_anchor_pack("机械深海,节奏偏爽", None)); - - assert_eq!(draft.levels.len(), BIG_FISH_DEFAULT_LEVEL_COUNT as usize); - assert_eq!(draft.runtime_params.merge_count_per_upgrade, 3); - assert_eq!(draft.runtime_params.offscreen_cull_seconds, 3.0); - assert_eq!(draft.runtime_params.prey_spawn_delta_levels, vec![1, 2]); - assert_eq!(draft.runtime_params.threat_spawn_delta_levels, vec![1, 2]); - assert!( - draft - .levels - .last() - .is_some_and(|level| level.is_final_level) - ); - } - - #[test] - fn asset_coverage_requires_main_images_two_motions_and_background() { - let draft = compile_default_draft(&infer_anchor_pack("深海", None)); - let coverage = build_asset_coverage(Some(&draft), &[]); - - assert!(!coverage.publish_ready); - assert_eq!(coverage.required_level_count, 8); - assert!( - coverage - .blockers - .iter() - .any(|item| item.contains("等级主图")) - ); - assert!( - coverage - .blockers - .iter() - .any(|item| item.contains("基础动作")) - ); - assert!(coverage.blockers.iter().any(|item| item.contains("背景图"))); - } -} diff --git a/server-rs/crates/module-custom-world/src/commands.rs b/server-rs/crates/module-custom-world/src/commands.rs index 6d3b23fd..638b5a65 100644 --- a/server-rs/crates/module-custom-world/src/commands.rs +++ b/server-rs/crates/module-custom-world/src/commands.rs @@ -84,6 +84,34 @@ pub struct CustomWorldGalleryDetailByCodeInput { pub public_work_code: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldProfileRemixInput { + pub source_owner_user_id: String, + pub source_profile_id: String, + pub target_owner_user_id: String, + pub target_profile_id: String, + pub author_display_name: String, + pub remixed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldProfilePlayRecordInput { + pub owner_user_id: String, + pub profile_id: String, + pub played_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldProfileLikeRecordInput { + pub owner_user_id: String, + pub profile_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CustomWorldAgentSessionCreateInput { diff --git a/server-rs/crates/module-custom-world/src/domain.rs b/server-rs/crates/module-custom-world/src/domain.rs index 0c8fd8fa..20492c54 100644 --- a/server-rs/crates/module-custom-world/src/domain.rs +++ b/server-rs/crates/module-custom-world/src/domain.rs @@ -155,6 +155,9 @@ pub struct CustomWorldProfileSnapshot { pub profile_payload_json: String, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, pub author_display_name: String, pub published_at_micros: Option, pub deleted_at_micros: Option, @@ -177,6 +180,10 @@ pub struct CustomWorldGalleryEntrySnapshot { pub theme_mode: CustomWorldThemeMode, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub recent_play_count_7d: u32, pub published_at_micros: i64, pub updated_at_micros: i64, } diff --git a/server-rs/crates/module-custom-world/src/lib.rs b/server-rs/crates/module-custom-world/src/lib.rs index 312ab928..6acdc7c7 100644 --- a/server-rs/crates/module-custom-world/src/lib.rs +++ b/server-rs/crates/module-custom-world/src/lib.rs @@ -9,329 +9,3 @@ pub use commands::*; pub use domain::*; pub use errors::*; pub use events::*; - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::Value; - - #[test] - fn profile_validation_rejects_blank_owner() { - let error = validate_custom_world_profile_fields( - "cwprof_001", - " ", - "裂潮边城", - "{\"id\":\"cwprof_001\"}", - ) - .expect_err("blank owner should fail"); - - assert_eq!(error, CustomWorldFieldError::MissingOwnerUserId); - } - - #[test] - fn agent_session_validation_rejects_progress_over_hundred() { - let error = validate_custom_world_agent_session_fields( - "custom-world-agent-session-001", - "user_001", - "{}", - "{}", - "[]", - "{}", - 101, - ) - .expect_err("progress greater than 100 should fail"); - - assert_eq!(error, CustomWorldFieldError::InvalidProgressPercent); - } - - #[test] - fn enum_string_values_match_current_contract() { - assert_eq!( - RpgAgentOperationType::PublishWorld.as_str(), - "publish_world" - ); - assert_eq!(RpgAgentStage::ReadyToPublish.as_str(), "ready_to_publish"); - assert_eq!(RpgAgentMessageRole::Assistant.as_str(), "assistant"); - assert_eq!(RpgAgentMessageKind::ActionResult.as_str(), "action_result"); - assert_eq!( - RpgAgentDraftCardKind::SceneChapter.as_str(), - "scene_chapter" - ); - assert_eq!( - CustomWorldRoleAssetStatus::VisualReady.as_str(), - "visual_ready" - ); - assert_eq!(CustomWorldThemeMode::Rift.as_str(), "rift"); - } - - #[test] - fn agent_session_create_input_validates_required_json_shapes() { - let input = CustomWorldAgentSessionCreateInput { - session_id: "custom-world-agent-session-001".to_string(), - owner_user_id: "user_001".to_string(), - seed_text: "".to_string(), - welcome_message_id: "message-001".to_string(), - welcome_message_text: "你好!我是你的世界设定助手。".to_string(), - anchor_content_json: empty_agent_anchor_content_json(), - creator_intent_json: Some(empty_json_object()), - creator_intent_readiness_json: empty_agent_creator_intent_readiness_json(), - anchor_pack_json: Some(empty_json_object()), - lock_state_json: Some(empty_json_object()), - draft_profile_json: Some(empty_json_object()), - pending_clarifications_json: empty_json_array(), - suggested_actions_json: empty_json_array(), - recommended_replies_json: empty_json_array(), - quality_findings_json: empty_json_array(), - asset_coverage_json: empty_agent_asset_coverage_json(), - checkpoints_json: empty_json_array(), - created_at_micros: 1, - }; - - validate_custom_world_agent_session_create_input(&input) - .expect("valid skeleton input should pass"); - } - - #[test] - fn profile_upsert_input_requires_author_display_name() { - let error = validate_custom_world_profile_upsert_input(&CustomWorldProfileUpsertInput { - profile_id: "cwprof_001".to_string(), - owner_user_id: "user_001".to_string(), - public_work_code: None, - author_public_user_code: None, - source_agent_session_id: None, - world_name: "裂潮边城".to_string(), - subtitle: "港口余烬".to_string(), - summary_text: "一座被裂潮与旧械共同撕扯的沿海城邦。".to_string(), - theme_mode: CustomWorldThemeMode::Tide, - cover_image_src: None, - profile_payload_json: "{\"id\":\"cwprof_001\"}".to_string(), - playable_npc_count: 3, - landmark_count: 2, - author_display_name: " ".to_string(), - updated_at_micros: 1, - }) - .expect_err("blank author display name should fail"); - - assert_eq!(error, CustomWorldFieldError::MissingAuthorDisplayName); - } - - #[test] - fn canonicalize_profile_before_save_rebuilds_setting_text_from_creator_intent() { - let mut profile = serde_json::json!({ - "id": "cwprof_001", - "settingText": "前端旧草稿文案", - "creatorIntent": { - "rawSettingText": "早期输入", - "worldHook": "海图会在午夜改写群岛航路", - "themeKeywords": ["海雾", "旧灯塔"], - "toneDirectives": ["克制", "悬疑"], - "playerPremise": "玩家是失忆领航员", - "openingSituation": "正在禁航区醒来", - "coreConflicts": ["议会隐瞒沉船真相"], - "keyCharacters": [{ - "name": "顾潮音", - "role": "守灯人", - "relationToPlayer": "旧识", - "hiddenHook": "掌握伪造海图" - }], - "iconicElements": ["会说谎的罗盘"] - } - }); - - assert!(canonicalize_custom_world_profile_before_save(&mut profile)); - assert_eq!( - profile.get("settingText").and_then(Value::as_str), - Some( - "世界一句话:海图会在午夜改写群岛航路\n玩家开局:玩家是失忆领航员;正在禁航区醒来\n主题气质:海雾、旧灯塔 / 克制、悬疑\n核心冲突:议会隐瞒沉船真相\n关键关系:顾潮音 · 守灯人 · 与玩家 旧识 · 暗线 掌握伪造海图\n标志元素:会说谎的罗盘" - ) - ); - } - - #[test] - fn canonicalize_profile_before_save_keeps_profile_without_creator_intent() { - let mut profile = serde_json::json!({ - "id": "cwprof_001", - "settingText": "用户手写设定" - }); - - assert!(!canonicalize_custom_world_profile_before_save(&mut profile)); - assert_eq!( - profile.get("settingText").and_then(Value::as_str), - Some("用户手写设定") - ); - } - - #[test] - fn profile_list_input_requires_owner_user_id() { - let error = validate_custom_world_profile_list_input(&CustomWorldProfileListInput { - owner_user_id: " ".to_string(), - }) - .expect_err("blank owner user id should fail"); - - assert_eq!(error, CustomWorldFieldError::MissingOwnerUserId); - } - - #[test] - fn profile_delete_input_requires_profile_and_owner() { - let error = validate_custom_world_profile_delete_input(&CustomWorldProfileDeleteInput { - profile_id: " ".to_string(), - owner_user_id: "user_001".to_string(), - deleted_at_micros: 1, - }) - .expect_err("blank profile id should fail"); - - assert_eq!(error, CustomWorldFieldError::MissingProfileId); - } - - #[test] - fn agent_message_finalize_requires_valid_json_payloads() { - let error = validate_custom_world_agent_message_finalize_input( - &CustomWorldAgentMessageFinalizeInput { - session_id: "session_001".to_string(), - owner_user_id: "user_001".to_string(), - operation_id: "operation_001".to_string(), - assistant_message_id: Some("message_001".to_string()), - assistant_reply_text: Some("已生成回复".to_string()), - phase_label: "消息已处理".to_string(), - phase_detail: "这一轮已完成推理并写回".to_string(), - operation_status: RpgAgentOperationStatus::Completed, - operation_progress: 100, - stage: RpgAgentStage::FoundationReview, - progress_percent: 100, - focus_card_id: None, - anchor_content_json: "[]".to_string(), - creator_intent_json: Some("{}".to_string()), - creator_intent_readiness_json: "{}".to_string(), - anchor_pack_json: Some("{}".to_string()), - draft_profile_json: Some("{}".to_string()), - pending_clarifications_json: "[]".to_string(), - suggested_actions_json: "[]".to_string(), - recommended_replies_json: "[]".to_string(), - quality_findings_json: "[]".to_string(), - asset_coverage_json: "{}".to_string(), - error_message: None, - updated_at_micros: 1, - }, - ) - .expect_err("invalid anchor content should fail"); - - assert_eq!(error, CustomWorldFieldError::InvalidJsonPayload); - } - - #[test] - fn agent_message_finalize_allows_missing_assistant_reply_when_failed() { - validate_custom_world_agent_message_finalize_input(&CustomWorldAgentMessageFinalizeInput { - session_id: "session_001".to_string(), - owner_user_id: "user_001".to_string(), - operation_id: "operation_001".to_string(), - assistant_message_id: None, - assistant_reply_text: None, - phase_label: "消息处理失败".to_string(), - phase_detail: "当前模型不可用,请稍后重试。".to_string(), - operation_status: RpgAgentOperationStatus::Failed, - operation_progress: 100, - stage: RpgAgentStage::Clarifying, - progress_percent: 20, - focus_card_id: None, - anchor_content_json: "{}".to_string(), - creator_intent_json: Some("{}".to_string()), - creator_intent_readiness_json: "{}".to_string(), - anchor_pack_json: Some("{}".to_string()), - draft_profile_json: Some("{}".to_string()), - pending_clarifications_json: "[]".to_string(), - suggested_actions_json: "[]".to_string(), - recommended_replies_json: "[]".to_string(), - quality_findings_json: "[]".to_string(), - asset_coverage_json: "{}".to_string(), - error_message: Some("当前模型不可用,请稍后重试。".to_string()), - updated_at_micros: 1, - }) - .expect("failed finalize should allow empty assistant message"); - } - - #[test] - fn published_profile_compile_merges_legacy_theme_and_latest_assets() { - let snapshot = build_custom_world_published_profile_compile_snapshot( - CustomWorldPublishedProfileCompileInput { - session_id: "session_001".to_string(), - profile_id: "agent-draft-session_001".to_string(), - owner_user_id: "user_001".to_string(), - draft_profile_json: r#"{ - "name":"潮雾列岛", - "subtitle":"旧灯塔与失控航路", - "summary":"第一版世界底稿已经整理完成。", - "tone":"压抑、潮湿、悬疑", - "playerGoal":"查清沉船与禁航区异动的真相。", - "playableNpcs":[{"id":"playable-1","name":"沈砺","imageSrc":"/generated/playable-1.png"}], - "storyNpcs":[{"id":"story-1","name":"顾潮音"}], - "landmarks":[{"id":"landmark-1","name":"回潮旧灯塔","imageSrc":"/generated/landmark-1.png"}], - "camp":{"id":"camp-1","name":"回潮暂栖所","imageSrc":"/generated/camp.png"}, - "sceneChapters":[{"id":"scene-chapter-1","sceneId":"landmark-1","title":"灯塔初章"}] - }"#.to_string(), - legacy_result_profile_json: Some( - r#"{ - "id":"legacy_profile", - "themeMode":"tide", - "themePack":{"id":"theme-pack:tide"}, - "storyGraph":{"visibleThreads":[{"id":"thread-1"}]} - }"# - .to_string(), - ), - setting_text: "被海雾吞没的旧航路群岛".to_string(), - author_display_name: "测试玩家".to_string(), - updated_at_micros: 42, - }, - ) - .expect("compile should succeed"); - - assert_eq!(snapshot.world_name, "潮雾列岛"); - assert_eq!(snapshot.theme_mode, CustomWorldThemeMode::Tide); - assert_eq!( - snapshot.cover_image_src.as_deref(), - Some("/generated/camp.png") - ); - assert_eq!(snapshot.playable_npc_count, 2); - assert_eq!(snapshot.landmark_count, 1); - assert!( - snapshot - .compiled_profile_payload_json - .contains("\"sceneChapterBlueprints\"") - ); - assert!( - snapshot - .compiled_profile_payload_json - .contains("\"themePack\"") - ); - } - - #[test] - fn published_profile_compile_defaults_theme_to_mythic_without_legacy_theme() { - let snapshot = build_custom_world_published_profile_compile_snapshot( - CustomWorldPublishedProfileCompileInput { - session_id: "session_002".to_string(), - profile_id: "profile_002".to_string(), - owner_user_id: "user_002".to_string(), - draft_profile_json: r#"{ - "name":"裂帆荒湾", - "subtitle":"雾岸残潮", - "summary":"港湾里还剩最后一条能退走的潮沟。", - "playableNpcs":[], - "storyNpcs":[], - "landmarks":[{"id":"landmark-1","name":"裂帆湾","imageSrc":"/generated/landmark-cover.png"}] - }"# - .to_string(), - legacy_result_profile_json: None, - setting_text: "被潮沟切开的荒湾".to_string(), - author_display_name: "玩家二号".to_string(), - updated_at_micros: 84, - }, - ) - .expect("compile should succeed"); - - assert_eq!(snapshot.theme_mode, CustomWorldThemeMode::Mythic); - assert_eq!( - snapshot.cover_image_src.as_deref(), - Some("/generated/landmark-cover.png") - ); - } -} diff --git a/server-rs/crates/module-match3d/Cargo.toml b/server-rs/crates/module-match3d/Cargo.toml new file mode 100644 index 00000000..5e5042f3 --- /dev/null +++ b/server-rs/crates/module-match3d/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "module-match3d" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +shared-kernel = { path = "../shared-kernel" } +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-match3d/src/application.rs b/server-rs/crates/module-match3d/src/application.rs new file mode 100644 index 00000000..0cb34c10 --- /dev/null +++ b/server-rs/crates/module-match3d/src/application.rs @@ -0,0 +1,879 @@ +use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list}; + +use crate::commands::{default_tags_for_theme, validate_result_publish_fields}; +use crate::{ + MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS, MATCH3D_BOARD_SAFE_MARGIN, + MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_FRUIT_VISUAL_KEYS, MATCH3D_ITEMS_PER_CLEAR, + MATCH3D_MAX_DIFFICULTY, MATCH3D_MIN_DIFFICULTY, MATCH3D_SHAPE_VISUAL_KEYS, + MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput, Match3DClickRejectReason, + Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError, Match3DItemSnapshot, + Match3DItemState, Match3DPublicationStatus, Match3DResultDraft, Match3DRunSnapshot, + Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile, +}; + +pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft { + let game_name = format!("{}抓大鹅", config.theme_text); + let summary = format!( + "{}主题,{} 次消除目标,难度 {}。", + config.theme_text, config.clear_count, config.difficulty + ); + let tags = default_tags_for_theme(&config.theme_text); + let mut draft = Match3DResultDraft { + game_name, + theme_text: config.theme_text.clone(), + summary, + tags, + cover_image_src: None, + reference_image_src: config.reference_image_src.clone(), + clear_count: config.clear_count, + difficulty: config.difficulty, + publish_ready: false, + blockers: Vec::new(), + }; + draft.blockers = validate_result_publish_fields(&draft); + draft.publish_ready = draft.blockers.is_empty(); + + draft +} + +/// 校验发布所需基础字段;试玩通关不是首版发布门槛。 + +pub fn create_work_profile( + work_id: String, + profile_id: String, + owner_user_id: String, + source_session_id: Option, + draft: &Match3DResultDraft, + updated_at_micros: i64, +) -> Result { + let work_id = normalize_required_string(work_id).ok_or(Match3DFieldError::MissingText)?; + let profile_id = + normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?; + let owner_user_id = + normalize_required_string(owner_user_id).ok_or(Match3DFieldError::MissingOwnerUserId)?; + + Ok(Match3DWorkProfile { + work_id, + profile_id, + owner_user_id, + source_session_id: normalize_optional_string(source_session_id), + game_name: draft.game_name.clone(), + theme_text: draft.theme_text.clone(), + summary: draft.summary.clone(), + tags: normalize_string_list(draft.tags.clone()), + cover_image_src: draft.cover_image_src.clone(), + reference_image_src: draft.reference_image_src.clone(), + clear_count: draft.clear_count, + difficulty: draft.difficulty, + publication_status: Match3DPublicationStatus::Draft, + play_count: 0, + updated_at_micros, + published_at_micros: None, + }) +} + +/// 发布作品时只改变发布状态和时间戳,不在领域层写数据库。 +pub fn publish_work_profile( + profile: &Match3DWorkProfile, + published_at_micros: i64, +) -> Result { + if profile.clear_count == 0 { + return Err(Match3DFieldError::InvalidClearCount); + } + if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&profile.difficulty) { + return Err(Match3DFieldError::InvalidDifficulty); + } + + let mut next = profile.clone(); + next.publication_status = Match3DPublicationStatus::Published; + next.updated_at_micros = published_at_micros; + next.published_at_micros = Some(published_at_micros); + Ok(next) +} + +/// 用确定性 seed 生成单局初始快照,便于后端权威复现和测试。 +pub fn start_run_with_seed_at( + run_id: String, + owner_user_id: String, + profile_id: String, + config: &Match3DCreatorConfig, + seed: u64, + started_at_ms: u64, +) -> Result { + let run_id = normalize_required_string(run_id).ok_or(Match3DFieldError::MissingRunId)?; + let owner_user_id = + normalize_required_string(owner_user_id).ok_or(Match3DFieldError::MissingOwnerUserId)?; + let profile_id = + normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?; + + let total_item_count = config + .clear_count + .checked_mul(MATCH3D_ITEMS_PER_CLEAR) + .ok_or(Match3DFieldError::InvalidClearCount)?; + let mut run = Match3DRunSnapshot { + run_id, + profile_id, + owner_user_id, + status: Match3DRunStatus::Running, + started_at_ms, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + clear_count: config.clear_count, + total_item_count, + cleared_item_count: 0, + board_version: 1, + items: build_initial_items( + config.clear_count, + config.difficulty, + seed, + &config.theme_text, + ), + tray_slots: empty_tray_slots(), + failure_reason: None, + last_confirmed_action_id: None, + }; + refresh_clickable_flags(&mut run); + Ok(run) +} + +/// 后端权威确认一次点击:校验版本、可点击性、入槽、三消和胜负。 +pub fn confirm_click_at( + run: &Match3DRunSnapshot, + input: &Match3DClickInput, +) -> Result { + let item_instance_id = normalize_required_string(&input.item_instance_id) + .ok_or(Match3DFieldError::MissingItemId)?; + let client_action_id = normalize_required_string(&input.client_action_id) + .unwrap_or_else(|| "match3d-action-unknown".to_string()); + + let mut next = resolve_run_timer_at(run, input.clicked_at_ms); + if next.status != Match3DRunStatus::Running { + return Ok(rejected(next, Match3DClickRejectReason::RunNotActive)); + } + if input.snapshot_version != next.board_version { + return Ok(rejected( + next, + Match3DClickRejectReason::SnapshotVersionMismatch, + )); + } + + let Some(item_index) = next + .items + .iter() + .position(|item| item.item_instance_id == item_instance_id) + else { + return Ok(rejected(next, Match3DClickRejectReason::ItemNotFound)); + }; + + if next.items[item_index].state != Match3DItemState::InBoard { + return Ok(rejected(next, Match3DClickRejectReason::ItemNotInBoard)); + } + if !next.items[item_index].clickable { + return Ok(rejected(next, Match3DClickRejectReason::ItemNotClickable)); + } + + let Some(slot_index) = first_empty_slot_index(&next.tray_slots) else { + next = fail_run(next, Match3DFailureReason::TrayFull, client_action_id); + return Ok(rejected(next, Match3DClickRejectReason::TrayFull)); + }; + + let item_type_id = next.items[item_index].item_type_id.clone(); + next.items[item_index].state = Match3DItemState::InTray; + next.items[item_index].clickable = false; + next.items[item_index].tray_slot_index = Some(slot_index); + fill_tray_slot(&mut next.tray_slots, slot_index, &next.items[item_index]); + + let cleared_item_instance_ids = clear_first_triple(&mut next, &item_type_id); + compact_tray(&mut next); + next.cleared_item_count = next + .items + .iter() + .filter(|item| item.state == Match3DItemState::Cleared) + .count() as u32; + + if next.cleared_item_count >= next.total_item_count { + next.status = Match3DRunStatus::Won; + } else if first_empty_slot_index(&next.tray_slots).is_none() { + next.status = Match3DRunStatus::Failed; + next.failure_reason = Some(Match3DFailureReason::TrayFull); + } + + refresh_clickable_flags(&mut next); + next.board_version += 1; + next.last_confirmed_action_id = Some(client_action_id); + + Ok(Match3DClickConfirmation { + accepted: true, + reject_reason: None, + entered_slot_index: Some(slot_index), + cleared_item_instance_ids, + run: next, + }) +} + +/// 根据权威时间刷新剩余时间;前端本地倒计时归零后仍需走后端确认。 +pub fn resolve_run_timer_at(run: &Match3DRunSnapshot, now_ms: u64) -> Match3DRunSnapshot { + let mut next = run.clone(); + if next.status != Match3DRunStatus::Running { + return next; + } + let elapsed_ms = now_ms.saturating_sub(next.started_at_ms); + next.remaining_ms = next.duration_limit_ms.saturating_sub(elapsed_ms); + if next.remaining_ms == 0 { + next.status = Match3DRunStatus::Failed; + next.failure_reason = Some(Match3DFailureReason::TimeUp); + next.board_version += 1; + } + next +} + +/// 停止当前运行态,用于试玩或玩家主动退出。 +pub fn stop_run_at(run: &Match3DRunSnapshot, stopped_action_id: String) -> Match3DRunSnapshot { + let mut next = run.clone(); + if next.status == Match3DRunStatus::Running { + next.status = Match3DRunStatus::Stopped; + next.board_version += 1; + next.last_confirmed_action_id = normalize_required_string(stopped_action_id); + } + next +} + +/// 以 2D 圆形近似判断遮挡:完全被更高层物品覆盖的物品不可点击。 +pub fn refresh_clickable_flags(run: &mut Match3DRunSnapshot) { + let board_items = run + .items + .iter() + .filter(|item| item.state == Match3DItemState::InBoard) + .cloned() + .collect::>(); + + for item in &mut run.items { + if item.state != Match3DItemState::InBoard { + item.clickable = false; + continue; + } + + item.clickable = !board_items.iter().any(|cover| { + cover.layer > item.layer + && fully_covers(cover.x, cover.y, cover.radius, item.x, item.y, item.radius) + }); + } +} + +fn build_initial_items( + clear_count: u32, + difficulty: u32, + seed: u64, + theme_text: &str, +) -> Vec { + let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64); + let base_radius = resolve_item_radius(difficulty); + let visual_keys = visual_keys_for_theme(theme_text); + let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize); + + for clear_index in 0..clear_count { + let visual_index = (clear_index as usize) % visual_keys.len(); + let item_type_id = format!("match3d-type-{:02}", visual_index + 1); + let visual_key = visual_keys[visual_index].to_string(); + + for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR { + let radius = + resolve_item_radius_variant(base_radius, &visual_key, visual_index, copy_index); + let (x, y) = random_point_in_circle(&mut rng, max_spawn_offset(radius)); + let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index; + items.push(Match3DItemSnapshot { + item_instance_id: format!("match3d-item-{instance_index:04}"), + item_type_id: item_type_id.clone(), + visual_key: visual_key.clone(), + x, + y, + radius, + layer: instance_index, + state: Match3DItemState::InBoard, + clickable: true, + tray_slot_index: None, + }); + } + } + + // 洗牌只改变层级顺序,不改变每组三个的可通关性。 + for index in (1..items.len()).rev() { + let swap_index = (rng.next_u32() as usize) % (index + 1); + items.swap(index, swap_index); + } + for (layer, item) in items.iter_mut().enumerate() { + item.layer = layer as u32; + } + + items +} + +fn visual_keys_for_theme(theme_text: &str) -> &'static [&'static str; 10] { + if is_fruit_theme(theme_text) { + &MATCH3D_FRUIT_VISUAL_KEYS + } else { + &MATCH3D_SHAPE_VISUAL_KEYS + } +} + +fn is_fruit_theme(theme_text: &str) -> bool { + let normalized = theme_text.trim().to_lowercase(); + [ + "水果", "果蔬", "果物", "fruit", "fruits", "苹果", "香蕉", "葡萄", "西瓜", "草莓", "桃", + "李", "柠", "橙", "梨", + ] + .iter() + .any(|marker| normalized.contains(marker)) +} + +fn resolve_item_radius(difficulty: u32) -> f32 { + let clamped = difficulty.clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY); + let radius = 0.105 - (clamped as f32 - 1.0) * 0.0055; + radius.max(0.052) +} + +fn resolve_item_radius_variant( + base_radius: f32, + visual_key: &str, + visual_index: usize, + copy_index: u32, +) -> f32 { + let copy_delta = (copy_index as f32 - 1.0) * 0.002; + if is_fruit_visual_key(visual_key) { + return (base_radius * fruit_visual_size_scale(visual_key) + copy_delta).clamp(0.04, 0.13); + } + + let type_delta = ((visual_index % 5) as f32 - 2.0) * 0.004; + (base_radius + type_delta + copy_delta).clamp(0.045, 0.12) +} + +fn is_fruit_visual_key(visual_key: &str) -> bool { + matches!( + visual_key, + "watermelon-green" + | "apple-red" + | "banana-yellow" + | "grape-purple" + | "melon-green" + | "berry-blue" + | "peach-pink" + | "plum-indigo" + | "lime-lime" + | "orange-orange" + | "pear-cyan" + ) +} + +fn fruit_visual_size_scale(visual_key: &str) -> f32 { + match visual_key { + "watermelon-green" => 1.24, + "melon-green" => 1.12, + "banana-yellow" => 1.04, + "apple-red" | "orange-orange" | "peach-pink" | "pear-cyan" => 1.0, + "plum-indigo" | "lime-lime" => 0.86, + "grape-purple" | "berry-blue" => 0.78, + _ => 1.0, + } +} + +fn max_spawn_offset(radius: f32) -> f32 { + (MATCH3D_BOARD_RADIUS - MATCH3D_BOARD_SAFE_MARGIN - radius).max(0.0) +} + +fn random_point_in_circle(rng: &mut DeterministicRng, max_radius: f32) -> (f32, f32) { + for _ in 0..24 { + let x = rng.next_unit_signed() * max_radius; + let y = rng.next_unit_signed() * max_radius; + if x * x + y * y <= max_radius * max_radius { + return (MATCH3D_BOARD_CENTER + x, MATCH3D_BOARD_CENTER + y); + } + } + (MATCH3D_BOARD_CENTER, MATCH3D_BOARD_CENTER) +} + +fn fully_covers( + cover_x: f32, + cover_y: f32, + cover_radius: f32, + item_x: f32, + item_y: f32, + item_radius: f32, +) -> bool { + let dx = cover_x - item_x; + let dy = cover_y - item_y; + let distance = (dx * dx + dy * dy).sqrt(); + distance + item_radius <= cover_radius * 0.96 +} + +fn empty_tray_slots() -> Vec { + (0..MATCH3D_TRAY_SLOT_COUNT) + .map(|slot_index| Match3DTraySlot { + slot_index, + item_instance_id: None, + item_type_id: None, + visual_key: None, + }) + .collect() +} + +fn first_empty_slot_index(slots: &[Match3DTraySlot]) -> Option { + slots + .iter() + .find(|slot| slot.item_instance_id.is_none()) + .map(|slot| slot.slot_index) +} + +fn fill_tray_slot(slots: &mut [Match3DTraySlot], slot_index: u32, item: &Match3DItemSnapshot) { + if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) { + slot.item_instance_id = Some(item.item_instance_id.clone()); + slot.item_type_id = Some(item.item_type_id.clone()); + slot.visual_key = Some(item.visual_key.clone()); + } +} + +fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec { + let matched_slot_item_ids = run + .tray_slots + .iter() + .filter(|slot| slot.item_type_id.as_deref() == Some(item_type_id)) + .filter_map(|slot| slot.item_instance_id.clone()) + .take(MATCH3D_ITEMS_PER_CLEAR as usize) + .collect::>(); + + if matched_slot_item_ids.len() < MATCH3D_ITEMS_PER_CLEAR as usize { + return Vec::new(); + } + + for item in &mut run.items { + if matched_slot_item_ids.contains(&item.item_instance_id) { + item.state = Match3DItemState::Cleared; + item.clickable = false; + item.tray_slot_index = None; + } + } + for slot in &mut run.tray_slots { + if slot + .item_instance_id + .as_ref() + .is_some_and(|id| matched_slot_item_ids.contains(id)) + { + slot.item_instance_id = None; + slot.item_type_id = None; + slot.visual_key = None; + } + } + + matched_slot_item_ids +} + +fn compact_tray(run: &mut Match3DRunSnapshot) { + let mut occupied = run + .tray_slots + .iter() + .filter_map(|slot| { + Some(( + slot.item_instance_id.clone()?, + slot.item_type_id.clone()?, + slot.visual_key.clone()?, + )) + }) + .collect::>(); + + for slot in &mut run.tray_slots { + slot.item_instance_id = None; + slot.item_type_id = None; + slot.visual_key = None; + } + + for (slot_index, (item_instance_id, item_type_id, visual_key)) in occupied.drain(..).enumerate() + { + let slot_index = slot_index as u32; + if let Some(slot) = run + .tray_slots + .iter_mut() + .find(|slot| slot.slot_index == slot_index) + { + slot.item_instance_id = Some(item_instance_id.clone()); + slot.item_type_id = Some(item_type_id); + slot.visual_key = Some(visual_key); + } + if let Some(item) = run + .items + .iter_mut() + .find(|item| item.item_instance_id == item_instance_id) + { + item.tray_slot_index = Some(slot_index); + } + } +} + +fn fail_run( + mut run: Match3DRunSnapshot, + reason: Match3DFailureReason, + action_id: String, +) -> Match3DRunSnapshot { + run.status = Match3DRunStatus::Failed; + run.failure_reason = Some(reason); + run.board_version += 1; + run.last_confirmed_action_id = Some(action_id); + run +} + +fn rejected( + run: Match3DRunSnapshot, + reject_reason: Match3DClickRejectReason, +) -> Match3DClickConfirmation { + Match3DClickConfirmation { + accepted: false, + reject_reason: Some(reject_reason), + entered_slot_index: None, + cleared_item_instance_ids: Vec::new(), + run, + } +} + +struct DeterministicRng { + state: u64, +} + +impl DeterministicRng { + fn new(seed: u64) -> Self { + Self { state: seed.max(1) } + } + + fn next_u32(&mut self) -> u32 { + let mut value = self.state; + value ^= value << 13; + value ^= value >> 7; + value ^= value << 17; + self.state = value; + (value >> 32) as u32 + } + + fn next_unit_signed(&mut self) -> f32 { + let value = self.next_u32() as f32 / u32::MAX as f32; + value * 2.0 - 1.0 + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::commands::{build_creator_config, validate_publish_requirements}; + + fn test_config(clear_count: u32) -> Match3DCreatorConfig { + build_creator_config("水果", None, clear_count, 4).expect("config should be valid") + } + + fn manual_item(id: &str, type_id: &str, slot: Option) -> Match3DItemSnapshot { + Match3DItemSnapshot { + item_instance_id: id.to_string(), + item_type_id: type_id.to_string(), + visual_key: type_id.to_string(), + x: 0.0, + y: 0.0, + radius: 0.08, + layer: 0, + state: if slot.is_some() { + Match3DItemState::InTray + } else { + Match3DItemState::InBoard + }, + clickable: slot.is_none(), + tray_slot_index: slot, + } + } + + #[test] + fn creator_config_requires_positive_clear_count() { + let error = build_creator_config("水果", None, 0, 3).expect_err("zero should fail"); + assert_eq!(error, Match3DFieldError::InvalidClearCount); + } + + #[test] + fn draft_requires_cover_before_publish() { + let mut draft = compile_result_draft(&test_config(2)); + + assert!(!draft.publish_ready); + assert!(draft.blockers.contains(&"封面图不能为空".to_string())); + + draft.cover_image_src = Some("https://example.com/cover.png".to_string()); + assert!(validate_publish_requirements(&draft).is_empty()); + } + + #[test] + fn initial_run_generates_triples() { + let run = start_run_with_seed_at( + "run-1".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(12), + 42, + 1_000, + ) + .expect("run should start"); + + assert_eq!(run.total_item_count, 36); + let mut counts = BTreeMap::::new(); + for item in &run.items { + *counts.entry(item.item_type_id.clone()).or_default() += 1; + } + assert!(counts.values().all(|count| count % 3 == 0)); + } + + #[test] + fn initial_run_uses_slightly_different_item_sizes() { + let run = start_run_with_seed_at( + "run-size".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(6), + 21, + 1_000, + ) + .expect("run should start"); + + let mut radii = run + .items + .iter() + .map(|item| (item.radius * 1_000.0).round() as u32) + .collect::>(); + radii.sort(); + radii.dedup(); + + assert!(radii.len() > 1); + } + + #[test] + fn fruit_theme_generates_fruit_visuals_inside_board() { + let run = start_run_with_seed_at( + "run-fruit".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(10), + 12, + 1_000, + ) + .expect("run should start"); + + let visual_keys = run + .items + .iter() + .map(|item| item.visual_key.as_str()) + .collect::>(); + assert!(visual_keys.contains(&"watermelon-green")); + assert!(visual_keys.contains(&"apple-red")); + assert!(visual_keys.contains(&"banana-yellow")); + assert!(!visual_keys.contains(&"red_circle")); + + for item in &run.items { + let dx = item.x - MATCH3D_BOARD_CENTER; + let dy = item.y - MATCH3D_BOARD_CENTER; + let distance = (dx * dx + dy * dy).sqrt(); + assert!( + distance + item.radius <= MATCH3D_BOARD_RADIUS - MATCH3D_BOARD_SAFE_MARGIN + 0.0001, + "item {} should stay inside board: x={}, y={}, radius={}", + item.item_instance_id, + item.x, + item.y, + item.radius + ); + } + } + + #[test] + fn fruit_theme_uses_common_sense_relative_sizes() { + let run = start_run_with_seed_at( + "run-fruit-size".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(10), + 27, + 1_000, + ) + .expect("run should start"); + + let max_radius_for_visual = |visual_key: &str| { + run.items + .iter() + .filter(|item| item.visual_key == visual_key) + .map(|item| item.radius) + .fold(0.0, f32::max) + }; + + let watermelon = max_radius_for_visual("watermelon-green"); + let apple = max_radius_for_visual("apple-red"); + let grape = max_radius_for_visual("grape-purple"); + + assert!(watermelon > apple); + assert!(apple > grape); + } + + #[test] + fn non_fruit_theme_generates_shape_visuals() { + let config = build_creator_config("玩具", None, 3, 4).expect("config should be valid"); + let run = start_run_with_seed_at( + "run-shapes".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &config, + 13, + 1_000, + ) + .expect("run should start"); + + let visual_keys = run + .items + .iter() + .map(|item| item.visual_key.as_str()) + .collect::>(); + assert!(visual_keys.contains(&"red_circle")); + assert!(visual_keys.contains(&"yellow_triangle")); + assert!(!visual_keys.contains(&"apple-red")); + } + + #[test] + fn clicking_three_same_items_clears_and_wins() { + let mut run = start_run_with_seed_at( + "run-1".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(1), + 7, + 10_000, + ) + .expect("run should start"); + for item in &mut run.items { + item.clickable = true; + } + + let ids = run + .items + .iter() + .map(|item| item.item_instance_id.clone()) + .collect::>(); + + for (index, item_id) in ids.iter().enumerate() { + let input = Match3DClickInput { + run_id: run.run_id.clone(), + owner_user_id: run.owner_user_id.clone(), + item_instance_id: item_id.clone(), + client_action_id: format!("action-{index}"), + snapshot_version: run.board_version, + clicked_at_ms: 11_000 + index as u64, + }; + run = confirm_click_at(&run, &input) + .expect("click should confirm") + .run; + } + + assert_eq!(run.status, Match3DRunStatus::Won); + assert_eq!(run.cleared_item_count, 3); + assert!( + run.tray_slots + .iter() + .all(|slot| slot.item_instance_id.is_none()) + ); + } + + #[test] + fn tray_full_fails_when_no_triple_can_clear() { + let mut run = Match3DRunSnapshot { + run_id: "run-full".to_string(), + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + status: Match3DRunStatus::Running, + started_at_ms: 0, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + clear_count: 3, + total_item_count: 9, + cleared_item_count: 0, + board_version: 1, + items: (0..8) + .map(|index| manual_item(&format!("item-{index}"), &format!("type-{index}"), None)) + .collect(), + tray_slots: empty_tray_slots(), + failure_reason: None, + last_confirmed_action_id: None, + }; + + for index in 0..7 { + let input = Match3DClickInput { + run_id: run.run_id.clone(), + owner_user_id: run.owner_user_id.clone(), + item_instance_id: format!("item-{index}"), + client_action_id: format!("action-{index}"), + snapshot_version: run.board_version, + clicked_at_ms: 1_000 + index, + }; + run = confirm_click_at(&run, &input) + .expect("click should confirm") + .run; + } + + assert_eq!(run.status, Match3DRunStatus::Failed); + assert_eq!(run.failure_reason, Some(Match3DFailureReason::TrayFull)); + } + + #[test] + fn timer_expiration_fails_running_run() { + let run = start_run_with_seed_at( + "run-1".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(2), + 9, + 1_000, + ) + .expect("run should start"); + + let expired = resolve_run_timer_at(&run, 1_000 + MATCH3D_DEFAULT_DURATION_LIMIT_MS); + + assert_eq!(expired.status, Match3DRunStatus::Failed); + assert_eq!(expired.failure_reason, Some(Match3DFailureReason::TimeUp)); + } + + #[test] + fn fully_covered_item_is_not_clickable() { + let mut run = Match3DRunSnapshot { + run_id: "run-cover".to_string(), + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + status: Match3DRunStatus::Running, + started_at_ms: 0, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + clear_count: 1, + total_item_count: 2, + cleared_item_count: 0, + board_version: 1, + items: vec![ + Match3DItemSnapshot { + layer: 0, + radius: 0.04, + ..manual_item("bottom", "type-a", None) + }, + Match3DItemSnapshot { + layer: 1, + radius: 0.08, + ..manual_item("top", "type-b", None) + }, + ], + tray_slots: empty_tray_slots(), + failure_reason: None, + last_confirmed_action_id: None, + }; + + refresh_clickable_flags(&mut run); + + let bottom = run + .items + .iter() + .find(|item| item.item_instance_id == "bottom") + .expect("bottom item should exist"); + assert!(!bottom.clickable); + } +} diff --git a/server-rs/crates/module-match3d/src/commands.rs b/server-rs/crates/module-match3d/src/commands.rs new file mode 100644 index 00000000..5fc7db10 --- /dev/null +++ b/server-rs/crates/module-match3d/src/commands.rs @@ -0,0 +1,86 @@ +use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list}; + +use crate::{ + MATCH3D_MAX_DIFFICULTY, MATCH3D_MIN_DIFFICULTY, Match3DCreatorConfig, Match3DFieldError, + Match3DResultDraft, +}; + +pub fn build_creator_config( + theme_text: &str, + reference_image_src: Option, + clear_count: u32, + difficulty: u32, +) -> Result { + let theme_text = normalize_required_string(theme_text).ok_or(Match3DFieldError::MissingText)?; + if clear_count == 0 { + return Err(Match3DFieldError::InvalidClearCount); + } + if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&difficulty) { + return Err(Match3DFieldError::InvalidDifficulty); + } + + Ok(Match3DCreatorConfig { + theme_text, + reference_image_src: normalize_optional_string(reference_image_src), + clear_count, + difficulty, + }) +} + +/// 根据已确认的题材、消除次数和难度编译首版结果草稿。 + +pub fn validate_publish_requirements(draft: &Match3DResultDraft) -> Vec { + let mut blockers = validate_result_publish_fields(draft); + if draft.clear_count == 0 { + blockers.push("需要消除次数必须为正整数".to_string()); + } + if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&draft.difficulty) { + blockers.push("难度必须在 1 到 10 之间".to_string()); + } + blockers +} + +/// 将结果草稿转换为可保存的作品 profile,实际持久化由 SpacetimeDB 分支负责。 + +pub(crate) fn validate_basic_publish_fields( + game_name: &str, + summary: &str, + tags: &[String], +) -> Vec { + let mut blockers = Vec::new(); + if normalize_required_string(game_name).is_none() { + blockers.push("游戏名称不能为空".to_string()); + } + if normalize_required_string(summary).is_none() { + blockers.push("简介不能为空".to_string()); + } + let normalized_tags = normalize_string_list(tags.to_vec()); + if normalized_tags.is_empty() { + blockers.push("至少需要 1 个标签".to_string()); + } + blockers +} + +pub(crate) fn validate_result_publish_fields(draft: &Match3DResultDraft) -> Vec { + let mut blockers = validate_basic_publish_fields(&draft.game_name, &draft.summary, &draft.tags); + if draft + .cover_image_src + .as_deref() + .and_then(normalize_required_string) + .is_none() + { + blockers.push("封面图不能为空".to_string()); + } + blockers +} + +pub(crate) fn default_tags_for_theme(theme_text: &str) -> Vec { + let mut tags = vec![ + "抓大鹅".to_string(), + "经典消除".to_string(), + theme_text.to_string(), + ]; + tags.sort(); + tags.dedup(); + tags +} diff --git a/server-rs/crates/module-match3d/src/domain.rs b/server-rs/crates/module-match3d/src/domain.rs new file mode 100644 index 00000000..890e5501 --- /dev/null +++ b/server-rs/crates/module-match3d/src/domain.rs @@ -0,0 +1,270 @@ +use serde::{Deserialize, Serialize}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const MATCH3D_SESSION_ID_PREFIX: &str = "match3d-session-"; +pub const MATCH3D_MESSAGE_ID_PREFIX: &str = "match3d-message-"; +pub const MATCH3D_PROFILE_ID_PREFIX: &str = "match3d-profile-"; +pub const MATCH3D_WORK_ID_PREFIX: &str = "match3d-work-"; +pub const MATCH3D_RUN_ID_PREFIX: &str = "match3d-run-"; +pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7; +pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3; +pub const MATCH3D_MIN_DIFFICULTY: u32 = 1; +pub const MATCH3D_MAX_DIFFICULTY: u32 = 10; +pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: u64 = 10 * 60 * 1000; +pub const MATCH3D_BOARD_CENTER: f32 = 0.5; +pub const MATCH3D_BOARD_RADIUS: f32 = 0.5; +pub const MATCH3D_BOARD_SAFE_MARGIN: f32 = 0.035; + +// 中文注释:首版 demo 不接真实图片生成,但水果题材必须先给出可辨认的水果内置视觉键。 +pub(crate) const MATCH3D_FRUIT_VISUAL_KEYS: [&str; 10] = [ + "watermelon-green", + "apple-red", + "banana-yellow", + "grape-purple", + "melon-green", + "berry-blue", + "peach-pink", + "plum-indigo", + "lime-lime", + "orange-orange", +]; + +// 中文注释:非水果题材使用颜色形状兜底 key;前端必须逐个渲染,不能统一兜成同一图案。 +pub(crate) const MATCH3D_SHAPE_VISUAL_KEYS: [&str; 10] = [ + "red_circle", + "yellow_triangle", + "purple_diamond", + "green_square", + "blue_star", + "orange_hexagon", + "cyan_capsule", + "pink_heart", + "lime_leaf", + "white_moon", +]; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DCreationStage { + CollectingConfig, + DraftReady, + ReadyToPublish, + Published, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DPublicationStatus { + Draft, + Published, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DRunStatus { + Running, + Won, + Failed, + Stopped, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DFailureReason { + TimeUp, + TrayFull, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DItemState { + InBoard, + InTray, + Cleared, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DClickRejectReason { + RunNotActive, + SnapshotVersionMismatch, + ItemNotFound, + ItemNotInBoard, + ItemNotClickable, + TrayFull, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DCreatorConfig { + pub theme_text: String, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DResultDraft { + pub game_name: String, + pub theme_text: String, + pub summary: String, + pub tags: Vec, + pub cover_image_src: Option, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publish_ready: bool, + pub blockers: Vec, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DWorkProfile { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub game_name: String, + pub theme_text: String, + pub summary: String, + pub tags: Vec, + pub cover_image_src: Option, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publication_status: Match3DPublicationStatus, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Match3DItemSnapshot { + pub item_instance_id: String, + pub item_type_id: String, + pub visual_key: String, + pub x: f32, + pub y: f32, + pub radius: f32, + pub layer: u32, + pub state: Match3DItemState, + pub clickable: bool, + pub tray_slot_index: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DTraySlot { + pub slot_index: u32, + pub item_instance_id: Option, + pub item_type_id: Option, + pub visual_key: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Match3DRunSnapshot { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: Match3DRunStatus, + pub started_at_ms: u64, + pub duration_limit_ms: u64, + pub remaining_ms: u64, + pub clear_count: u32, + pub total_item_count: u32, + pub cleared_item_count: u32, + /// 领域内部权威快照版本;HTTP DTO 对外映射为 snapshotVersion。 + pub board_version: u64, + pub items: Vec, + pub tray_slots: Vec, + pub failure_reason: Option, + pub last_confirmed_action_id: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DClickInput { + pub run_id: String, + pub owner_user_id: String, + pub item_instance_id: String, + pub client_action_id: String, + pub snapshot_version: u64, + pub clicked_at_ms: u64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Match3DClickConfirmation { + pub accepted: bool, + pub reject_reason: Option, + pub entered_slot_index: Option, + pub cleared_item_instance_ids: Vec, + pub run: Match3DRunSnapshot, +} + +impl Match3DCreationStage { + pub fn as_str(self) -> &'static str { + match self { + Self::CollectingConfig => "collecting_config", + Self::DraftReady => "draft_ready", + Self::ReadyToPublish => "ready_to_publish", + Self::Published => "published", + } + } +} + +impl Match3DPublicationStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Draft => "draft", + Self::Published => "published", + } + } +} + +impl Match3DRunStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::Won => "won", + Self::Failed => "failed", + Self::Stopped => "stopped", + } + } +} + +impl Match3DFailureReason { + pub fn as_str(self) -> &'static str { + match self { + Self::TimeUp => "time_up", + Self::TrayFull => "tray_full", + } + } +} + +impl Match3DItemState { + pub fn as_str(self) -> &'static str { + match self { + Self::InBoard => "in_board", + Self::InTray => "in_tray", + Self::Cleared => "cleared", + } + } +} + +impl Match3DClickRejectReason { + pub fn as_str(self) -> &'static str { + match self { + Self::RunNotActive => "run_not_active", + Self::SnapshotVersionMismatch => "snapshot_version_mismatch", + Self::ItemNotFound => "item_not_found", + Self::ItemNotInBoard => "item_not_in_board", + Self::ItemNotClickable => "item_not_clickable", + Self::TrayFull => "tray_full", + } + } +} diff --git a/server-rs/crates/module-match3d/src/errors.rs b/server-rs/crates/module-match3d/src/errors.rs new file mode 100644 index 00000000..f8ceb485 --- /dev/null +++ b/server-rs/crates/module-match3d/src/errors.rs @@ -0,0 +1,28 @@ +use std::{error::Error, fmt}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Match3DFieldError { + MissingText, + MissingOwnerUserId, + MissingProfileId, + MissingRunId, + MissingItemId, + InvalidClearCount, + InvalidDifficulty, +} + +impl fmt::Display for Match3DFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingText => write!(f, "必填文本缺失"), + Self::MissingOwnerUserId => write!(f, "owner_user_id 缺失"), + Self::MissingProfileId => write!(f, "profile_id 缺失"), + Self::MissingRunId => write!(f, "run_id 缺失"), + Self::MissingItemId => write!(f, "item_instance_id 缺失"), + Self::InvalidClearCount => write!(f, "需要消除次数必须为正整数"), + Self::InvalidDifficulty => write!(f, "难度必须在 1 到 10 之间"), + } + } +} + +impl Error for Match3DFieldError {} diff --git a/server-rs/crates/module-match3d/src/events.rs b/server-rs/crates/module-match3d/src/events.rs new file mode 100644 index 00000000..51c701f8 --- /dev/null +++ b/server-rs/crates/module-match3d/src/events.rs @@ -0,0 +1,25 @@ +//! Match3D 领域事件。 +//! +//! 事件只表达已经发生的领域事实,持久化、订阅投影和 HTTP 通知 +//! 均由 SpacetimeDB adapter 或 BFF 决定。 + +/// Match3D 领域事件。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Match3DDomainEvent { + DraftCompiled { + profile_id: String, + owner_user_id: String, + occurred_at_micros: i64, + }, + WorkPublished { + profile_id: String, + owner_user_id: String, + occurred_at_micros: i64, + }, + RunSettled { + run_id: String, + owner_user_id: String, + status: String, + occurred_at_micros: i64, + }, +} diff --git a/server-rs/crates/module-match3d/src/lib.rs b/server-rs/crates/module-match3d/src/lib.rs new file mode 100644 index 00000000..6acdc7c7 --- /dev/null +++ b/server-rs/crates/module-match3d/src/lib.rs @@ -0,0 +1,11 @@ +mod application; +mod commands; +mod domain; +mod errors; +mod events; + +pub use application::*; +pub use commands::*; +pub use domain::*; +pub use errors::*; +pub use events::*; diff --git a/server-rs/crates/module-puzzle/Cargo.toml b/server-rs/crates/module-puzzle/Cargo.toml index 7ed854eb..abcd920f 100644 --- a/server-rs/crates/module-puzzle/Cargo.toml +++ b/server-rs/crates/module-puzzle/Cargo.toml @@ -10,6 +10,5 @@ spacetime-types = ["dep:spacetimedb"] [dependencies] serde = { version = "1", features = ["derive"] } -serde_json = "1" shared-kernel = { path = "../shared-kernel" } spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index 720584d0..02140232 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -81,8 +81,19 @@ pub fn empty_anchor_pack() -> PuzzleAnchorPack { pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> PuzzleAnchorPack { let source = normalize_required_string(latest_message.unwrap_or(seed_text)) - .or_else(|| normalize_required_string(seed_text)) - .unwrap_or_else(|| "童话森林里的发光猫咪遗迹".to_string()); + .or_else(|| normalize_required_string(seed_text)); + let Some(source) = source else { + return empty_anchor_pack(); + }; + if let Some(form_seed) = parse_form_seed_text(&source) { + if form_seed.has_any_value() { + return build_form_anchor_pack( + form_seed.work_title.as_deref().unwrap_or(""), + form_seed.picture_description.as_deref().unwrap_or(""), + ); + } + } + let mut pack = empty_anchor_pack(); pack.theme_promise.value = infer_theme_promise(&source); pack.theme_promise.status = PuzzleAnchorStatus::Inferred; @@ -97,12 +108,42 @@ pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> Puzzl pack } +pub fn build_form_anchor_pack(title: &str, picture_description: &str) -> PuzzleAnchorPack { + let normalized_title = normalize_required_string(title); + let normalized_description = normalize_required_string(picture_description); + let mut pack = empty_anchor_pack(); + + if let Some(title) = normalized_title.as_ref() { + pack.theme_promise.value = title.clone(); + pack.theme_promise.status = PuzzleAnchorStatus::Locked; + } + if let Some(description) = normalized_description.as_ref() { + pack.visual_subject.value = description.clone(); + pack.visual_subject.status = PuzzleAnchorStatus::Locked; + } + pack.visual_mood.value = "清晰、适合拼图切块".to_string(); + pack.visual_mood.status = PuzzleAnchorStatus::Inferred; + pack.composition_hooks.value = "主体轮廓、色块分区、局部细节".to_string(); + pack.composition_hooks.status = PuzzleAnchorStatus::Inferred; + pack.tags_and_forbidden.value = build_form_tags_and_forbidden( + normalized_title.as_deref().unwrap_or(""), + normalized_description.as_deref().unwrap_or(""), + ); + pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred; + + pack +} + pub fn build_creator_intent( anchor_pack: &PuzzleAnchorPack, messages: &[PuzzleAgentMessageSnapshot], ) -> PuzzleCreatorIntent { PuzzleCreatorIntent { - source_mode: "agent_chat".to_string(), + source_mode: if is_form_anchor_pack(anchor_pack) { + "form".to_string() + } else { + "agent_chat".to_string() + }, raw_messages_summary: messages .iter() .rev() @@ -127,18 +168,37 @@ pub fn build_creator_intent( pub fn compile_result_draft( anchor_pack: &PuzzleAnchorPack, messages: &[PuzzleAgentMessageSnapshot], +) -> PuzzleResultDraft { + compile_result_draft_from_seed(anchor_pack, messages, None) +} + +pub fn compile_result_draft_from_seed( + anchor_pack: &PuzzleAnchorPack, + messages: &[PuzzleAgentMessageSnapshot], + seed_text: Option<&str>, ) -> PuzzleResultDraft { let creator_intent = build_creator_intent(anchor_pack, messages); let normalized_tags = normalize_theme_tags(creator_intent.theme_tags.clone()); - let level_name = build_level_name(anchor_pack, &normalized_tags); + let work_title = build_work_title(anchor_pack); + let work_description = resolve_work_description(seed_text, anchor_pack); + let picture_description = fallback_text(&anchor_pack.visual_subject.value, "画面主体"); + let level_name = + build_level_name_from_picture(picture_description.as_str(), &normalized_tags, 1); + let level = PuzzleDraftLevel { + level_id: "puzzle-level-1".to_string(), + level_name: level_name.clone(), + picture_description, + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + }; PuzzleResultDraft { + work_title, + work_description: work_description.clone(), level_name, - summary: format!( - "{},主体是{},氛围偏{}。", - fallback_text(&anchor_pack.theme_promise.value, "梦幻题材"), - fallback_text(&anchor_pack.visual_subject.value, "画面主体"), - fallback_text(&anchor_pack.visual_mood.value, "温暖") - ), + summary: work_description, theme_tags: normalized_tags, forbidden_directives: creator_intent.forbidden_directives.clone(), creator_intent: Some(creator_intent), @@ -148,6 +208,79 @@ pub fn compile_result_draft( cover_image_src: None, cover_asset_id: None, generation_status: "idle".to_string(), + levels: vec![level], + form_draft: None, + } +} + +pub fn build_form_draft_from_seed( + anchor_pack: &PuzzleAnchorPack, + seed_text: Option<&str>, +) -> PuzzleResultDraft { + let form_seed = seed_text.and_then(parse_form_seed_text); + build_form_draft_from_parts( + anchor_pack, + form_seed.as_ref().and_then(|seed| seed.work_title.clone()), + form_seed + .as_ref() + .and_then(|seed| seed.work_description.clone()), + form_seed.and_then(|seed| seed.picture_description), + ) +} + +pub fn build_form_draft_from_parts( + anchor_pack: &PuzzleAnchorPack, + work_title: Option, + work_description: Option, + picture_description: Option, +) -> PuzzleResultDraft { + let work_title = work_title.and_then(|value| normalize_required_string(&value)); + let work_description = work_description.and_then(|value| normalize_required_string(&value)); + let picture_description = + picture_description.and_then(|value| normalize_required_string(&value)); + let title_for_tags = work_title.as_deref().unwrap_or(""); + let picture_for_tags = picture_description.as_deref().unwrap_or(""); + let mut tags = normalize_theme_tags(derive_form_theme_tags(title_for_tags, picture_for_tags)); + if tags.is_empty() { + tags = vec![ + "拼图".to_string(), + "插画".to_string(), + "清晰构图".to_string(), + ]; + } + let summary = work_description.clone().unwrap_or_default(); + let level = PuzzleDraftLevel { + level_id: "puzzle-level-1".to_string(), + level_name: String::new(), + picture_description: picture_description.clone().unwrap_or_default(), + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + }; + + // 中文注释:这是生成前的表单草稿,只用于创作中心恢复和表单回填,不进入发布就绪判断。 + PuzzleResultDraft { + work_title: work_title.clone().unwrap_or_default(), + work_description: summary.clone(), + level_name: String::new(), + summary, + theme_tags: tags, + forbidden_directives: Vec::new(), + creator_intent: None, + anchor_pack: anchor_pack.clone(), + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + levels: vec![level], + form_draft: Some(PuzzleFormDraft { + work_title, + work_description, + picture_description, + }), } } @@ -215,13 +348,155 @@ pub fn apply_selected_candidate( Ok(draft) } +pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft { + if draft.work_title.trim().is_empty() { + draft.work_title = fallback_text(&draft.anchor_pack.theme_promise.value, &draft.level_name); + } + if draft.work_description.trim().is_empty() { + draft.work_description = draft.summary.clone(); + } + if draft.levels.is_empty() { + draft.levels = vec![PuzzleDraftLevel { + level_id: "puzzle-level-1".to_string(), + level_name: draft.level_name.clone(), + picture_description: fallback_text( + &draft.anchor_pack.visual_subject.value, + &draft.summary, + ), + candidates: draft.candidates.clone(), + selected_candidate_id: draft.selected_candidate_id.clone(), + cover_image_src: draft.cover_image_src.clone(), + cover_asset_id: draft.cover_asset_id.clone(), + generation_status: draft.generation_status.clone(), + }]; + } + sync_primary_level_fields(&mut draft); + draft +} + +pub fn sync_primary_level_fields(draft: &mut PuzzleResultDraft) { + if let Some(primary_level) = draft.levels.first() { + draft.level_name = primary_level.level_name.clone(); + draft.candidates = primary_level.candidates.clone(); + draft.selected_candidate_id = primary_level.selected_candidate_id.clone(); + draft.cover_image_src = primary_level.cover_image_src.clone(); + draft.cover_asset_id = primary_level.cover_asset_id.clone(); + draft.generation_status = primary_level.generation_status.clone(); + } + if draft.work_description.trim().is_empty() { + draft.work_description = draft.summary.clone(); + } + draft.summary = draft.work_description.clone(); + if draft.form_draft.is_some() { + draft.form_draft = Some(PuzzleFormDraft { + work_title: normalize_required_string(&draft.work_title), + work_description: normalize_required_string(&draft.work_description), + picture_description: draft + .levels + .first() + .and_then(|level| normalize_required_string(&level.picture_description)), + }); + } +} + +pub fn selected_puzzle_level( + draft: &PuzzleResultDraft, + level_id: Option<&str>, +) -> Option { + let normalized = normalize_puzzle_draft(draft.clone()); + let requested_level_id = level_id.and_then(normalize_required_string); + requested_level_id + .as_deref() + .and_then(|target_id| { + normalized + .levels + .iter() + .find(|level| level.level_id == target_id) + .cloned() + }) + .or_else(|| normalized.levels.first().cloned()) +} + +pub fn replace_puzzle_level( + draft: &PuzzleResultDraft, + level: PuzzleDraftLevel, +) -> Result { + let mut next_draft = normalize_puzzle_draft(draft.clone()); + let Some(index) = next_draft + .levels + .iter() + .position(|entry| entry.level_id == level.level_id) + else { + return Err(PuzzleFieldError::InvalidOperation); + }; + next_draft.levels[index] = level; + sync_primary_level_fields(&mut next_draft); + Ok(next_draft) +} + +pub fn append_blank_puzzle_level(draft: &PuzzleResultDraft) -> PuzzleResultDraft { + let mut next_draft = normalize_puzzle_draft(draft.clone()); + let next_index = next_draft.levels.len() + 1; + let picture_description = next_draft + .levels + .first() + .map(|level| level.picture_description.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| fallback_text(&next_draft.anchor_pack.visual_subject.value, "画面主体")); + next_draft.levels.push(PuzzleDraftLevel { + level_id: format!("puzzle-level-{next_index}"), + level_name: build_level_name_from_picture( + picture_description.as_str(), + &next_draft.theme_tags, + next_index, + ), + picture_description, + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + }); + sync_primary_level_fields(&mut next_draft); + next_draft +} + +pub fn remove_puzzle_level( + draft: &PuzzleResultDraft, + level_id: &str, +) -> Result { + let mut next_draft = normalize_puzzle_draft(draft.clone()); + if next_draft.levels.len() <= 1 { + return Err(PuzzleFieldError::InvalidOperation); + } + let normalized_level_id = + normalize_required_string(level_id).ok_or(PuzzleFieldError::InvalidOperation)?; + next_draft + .levels + .retain(|level| level.level_id != normalized_level_id); + if next_draft.levels.is_empty() { + return Err(PuzzleFieldError::InvalidOperation); + } + sync_primary_level_fields(&mut next_draft); + Ok(next_draft) +} + pub fn build_result_preview( draft: &PuzzleResultDraft, author_display_name: Option<&str>, ) -> PuzzleResultPreviewEnvelope { - let blockers = validate_publish_requirements(draft, author_display_name); + let normalized_draft = normalize_puzzle_draft(draft.clone()); + if normalized_draft.form_draft.is_some() { + return PuzzleResultPreviewEnvelope { + draft: normalized_draft, + blockers: Vec::new(), + quality_findings: Vec::new(), + publish_ready: false, + }; + } + let blockers = validate_publish_requirements(&normalized_draft, author_display_name); PuzzleResultPreviewEnvelope { - draft: draft.clone(), + draft: normalized_draft, blockers, quality_findings: Vec::new(), publish_ready: validate_publish_requirements(draft, author_display_name).is_empty(), @@ -232,27 +507,44 @@ pub fn validate_publish_requirements( draft: &PuzzleResultDraft, author_display_name: Option<&str>, ) -> Vec { + let draft = normalize_puzzle_draft(draft.clone()); let mut blockers = Vec::new(); - if normalize_required_string(&draft.level_name).is_none() { + if normalize_required_string(&draft.work_title).is_none() { blockers.push(PuzzleResultPreviewBlocker { - id: "missing-level-name".to_string(), - code: "MISSING_LEVEL_NAME".to_string(), - message: "关卡名不能为空".to_string(), + id: "missing-work-title".to_string(), + code: "MISSING_WORK_TITLE".to_string(), + message: "作品名称不能为空".to_string(), }); } - if draft - .cover_image_src - .as_deref() - .map(str::trim) - .unwrap_or("") - .is_empty() - { + if normalize_required_string(&draft.work_description).is_none() { blockers.push(PuzzleResultPreviewBlocker { - id: "missing-cover-image".to_string(), - code: "MISSING_COVER_IMAGE".to_string(), - message: "正式拼图图片尚未确定".to_string(), + id: "missing-work-description".to_string(), + code: "MISSING_WORK_DESCRIPTION".to_string(), + message: "作品描述不能为空".to_string(), }); } + for level in &draft.levels { + if normalize_required_string(&level.level_name).is_none() { + blockers.push(PuzzleResultPreviewBlocker { + id: format!("missing-level-name-{}", level.level_id), + code: "MISSING_LEVEL_NAME".to_string(), + message: "关卡名不能为空".to_string(), + }); + } + if level + .cover_image_src + .as_deref() + .map(str::trim) + .unwrap_or("") + .is_empty() + { + blockers.push(PuzzleResultPreviewBlocker { + id: format!("missing-cover-image-{}", level.level_id), + code: "MISSING_COVER_IMAGE".to_string(), + message: "正式拼图图片尚未确定".to_string(), + }); + } + } if draft.theme_tags.len() < PUZZLE_MIN_TAG_COUNT || draft.theme_tags.len() > PUZZLE_MAX_TAG_COUNT { @@ -283,22 +575,31 @@ pub fn create_work_profile( ) -> Result { let author_display_name = normalize_required_string(author_display_name) .ok_or(PuzzleFieldError::MissingAuthorDisplayName)?; - let preview = build_result_preview(draft, Some(&author_display_name)); + let draft = normalize_puzzle_draft(draft.clone()); + let preview = build_result_preview(&draft, Some(&author_display_name)); Ok(PuzzleWorkProfile { work_id, profile_id, owner_user_id, source_session_id, author_display_name, + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), level_name: draft.level_name.clone(), summary: draft.summary.clone(), theme_tags: normalize_theme_tags(draft.theme_tags.clone()), cover_image_src: draft.cover_image_src.clone(), cover_asset_id: draft.cover_asset_id.clone(), + levels: draft.levels.clone(), publication_status: PuzzlePublicationStatus::Draft, updated_at_micros, published_at_micros: None, play_count: 0, + remix_count: 0, + like_count: 0, + recent_play_count_7d: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, publish_ready: preview.publish_ready, anchor_pack: draft.anchor_pack.clone(), }) @@ -309,14 +610,18 @@ pub fn publish_work_profile( draft: &PuzzleResultDraft, published_at_micros: i64, ) -> Result { - if !validate_publish_requirements(draft, Some(&profile.author_display_name)).is_empty() { + let draft = normalize_puzzle_draft(draft.clone()); + if !validate_publish_requirements(&draft, Some(&profile.author_display_name)).is_empty() { return Err(PuzzleFieldError::InvalidOperation); } + profile.work_title = draft.work_title.clone(); + profile.work_description = draft.work_description.clone(); profile.level_name = draft.level_name.clone(); profile.summary = draft.summary.clone(); profile.theme_tags = normalize_theme_tags(draft.theme_tags.clone()); profile.cover_image_src = draft.cover_image_src.clone(); profile.cover_asset_id = draft.cover_asset_id.clone(); + profile.levels = draft.levels.clone(); profile.publication_status = PuzzlePublicationStatus::Published; profile.publish_ready = true; profile.updated_at_micros = published_at_micros; @@ -328,22 +633,39 @@ pub fn publish_work_profile( /// 这里只允许覆盖 PRD 明确要求的关卡名、摘要与标签,不额外扩到更多结果页元数据。 pub fn apply_publish_overrides_to_draft( draft: &PuzzleResultDraft, + work_title: Option, + work_description: Option, level_name: Option, summary: Option, theme_tags: Option>, + levels: Option>, ) -> Result { - let mut next_draft = draft.clone(); + let mut next_draft = normalize_puzzle_draft(draft.clone()); + + if let Some(next_work_title) = work_title + && let Some(normalized_work_title) = normalize_required_string(&next_work_title) + { + next_draft.work_title = normalized_work_title; + } + + if let Some(next_work_description) = work_description + && let Some(normalized_work_description) = normalize_required_string(&next_work_description) + { + next_draft.work_description = normalized_work_description; + } if let Some(next_level_name) = level_name && let Some(normalized_level_name) = normalize_required_string(&next_level_name) { - next_draft.level_name = normalized_level_name; + if let Some(primary_level) = next_draft.levels.first_mut() { + primary_level.level_name = normalized_level_name; + } } if let Some(next_summary) = summary && let Some(normalized_summary) = normalize_required_string(&next_summary) { - next_draft.summary = normalized_summary; + next_draft.work_description = normalized_summary; } if let Some(next_theme_tags) = theme_tags { @@ -356,11 +678,293 @@ pub fn apply_publish_overrides_to_draft( next_draft.theme_tags = normalized_theme_tags; } + if let Some(next_levels) = levels { + let normalized_levels = normalize_puzzle_levels(next_levels, &next_draft.theme_tags)?; + next_draft.levels = normalized_levels; + } + + sync_primary_level_fields(&mut next_draft); Ok(next_draft) } +pub fn normalize_puzzle_levels( + levels: Vec, + theme_tags: &[String], +) -> Result, PuzzleFieldError> { + let mut normalized_levels = Vec::new(); + for (index, mut level) in levels.into_iter().enumerate() { + let level_id = normalize_required_string(&level.level_id) + .unwrap_or_else(|| format!("puzzle-level-{}", index + 1)); + let picture_description = normalize_required_string(&level.picture_description) + .unwrap_or_else(|| format!("第{}关画面", index + 1)); + let level_name = normalize_required_string(&level.level_name).unwrap_or_else(|| { + build_level_name_from_picture(picture_description.as_str(), theme_tags, index + 1) + }); + level.level_id = level_id; + level.level_name = level_name; + level.picture_description = picture_description; + level.generation_status = normalize_required_string(&level.generation_status) + .unwrap_or_else(|| "idle".to_string()); + normalized_levels.push(level); + } + if normalized_levels.is_empty() { + return Err(PuzzleFieldError::InvalidOperation); + } + Ok(normalized_levels) +} + +pub fn is_supported_puzzle_grid_size(grid_size: u32) -> bool { + PUZZLE_SUPPORTED_GRID_SIZES.contains(&grid_size) +} + +pub fn resolve_puzzle_level_config(level_index: u32) -> PuzzleLevelConfig { + let level_index = level_index.max(1); + match level_index { + 1 => PuzzleLevelConfig { + grid_size: 3, + time_limit_ms: 300_000, + }, + 2 => PuzzleLevelConfig { + grid_size: 4, + time_limit_ms: 300_000, + }, + 3 => PuzzleLevelConfig { + grid_size: 5, + time_limit_ms: 300_000, + }, + 4 => PuzzleLevelConfig { + grid_size: 5, + time_limit_ms: 210_000, + }, + _ => { + let loop_index = (level_index.saturating_sub(5) % 6) + 5; + match loop_index { + 5 => PuzzleLevelConfig { + grid_size: 5, + time_limit_ms: 210_000, + }, + 6 => PuzzleLevelConfig { + grid_size: 6, + time_limit_ms: 240_000, + }, + 7 => PuzzleLevelConfig { + grid_size: 5, + time_limit_ms: 210_000, + }, + 8 => PuzzleLevelConfig { + grid_size: 7, + time_limit_ms: 270_000, + }, + 9 => PuzzleLevelConfig { + grid_size: 5, + time_limit_ms: 240_000, + }, + _ => PuzzleLevelConfig { + grid_size: 7, + time_limit_ms: 270_000, + }, + } + } + } +} + pub fn resolve_puzzle_grid_size(cleared_level_count: u32) -> u32 { - if cleared_level_count >= 3 { 4 } else { 3 } + resolve_puzzle_level_config(cleared_level_count + 1).grid_size +} + +pub fn resolve_puzzle_level_time_limit_ms_by_index(level_index: u32) -> u64 { + resolve_puzzle_level_config(level_index.max(1)).time_limit_ms +} + +pub fn resolve_puzzle_level_time_limit_ms(grid_size: u32) -> u64 { + match grid_size { + 3 | 4 | 5 => 300_000, + 6 => 240_000, + 7 => 270_000, + _ => 300_000, + } +} + +pub fn resolve_puzzle_runtime_remaining_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 { + let time_limit_ms = if level.time_limit_ms == 0 { + resolve_puzzle_level_time_limit_ms_by_index(level.level_index) + } else { + level.time_limit_ms + }; + time_limit_ms.saturating_sub(resolve_effective_elapsed_ms(level, now_ms)) +} + +fn normalize_timer_fields(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) { + if level.started_at_ms == 0 { + level.started_at_ms = now_ms; + } + if level.time_limit_ms == 0 { + level.time_limit_ms = resolve_puzzle_level_time_limit_ms_by_index(level.level_index); + } + if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing { + level.remaining_ms = level.time_limit_ms; + } +} + +fn resolve_active_freeze_elapsed_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 { + match (level.freeze_started_at_ms, level.freeze_until_ms) { + (Some(started_at), Some(until_ms)) => now_ms.min(until_ms).saturating_sub(started_at), + _ => 0, + } +} + +fn resolve_effective_elapsed_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 { + let pause_elapsed_ms = level + .pause_started_at_ms + .map(|started_at| now_ms.saturating_sub(started_at)) + .unwrap_or(0); + now_ms + .saturating_sub(level.started_at_ms) + .saturating_sub(level.paused_accumulated_ms) + .saturating_sub(pause_elapsed_ms) + .saturating_sub(level.freeze_accumulated_ms) + .saturating_sub(resolve_active_freeze_elapsed_ms(level, now_ms)) +} + +fn settle_expired_freeze(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) { + let (Some(started_at), Some(until_ms)) = (level.freeze_started_at_ms, level.freeze_until_ms) + else { + return; + }; + if now_ms < until_ms { + return; + } + level.freeze_accumulated_ms = level + .freeze_accumulated_ms + .saturating_add(until_ms.saturating_sub(started_at)); + level.freeze_started_at_ms = None; + level.freeze_until_ms = None; +} + +fn close_level_pause(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) { + if let Some(pause_started_at_ms) = level.pause_started_at_ms.take() { + level.paused_accumulated_ms = level + .paused_accumulated_ms + .saturating_add(now_ms.saturating_sub(pause_started_at_ms)); + } +} + +pub fn resolve_puzzle_run_timer_at(mut run: PuzzleRunSnapshot, now_ms: u64) -> PuzzleRunSnapshot { + let Some(current_level) = run.current_level.as_mut() else { + return run; + }; + normalize_timer_fields(current_level, now_ms); + if current_level.status != PuzzleRuntimeLevelStatus::Playing { + return run; + } + + settle_expired_freeze(current_level, now_ms); + let effective_elapsed_ms = resolve_effective_elapsed_ms(current_level, now_ms); + current_level.remaining_ms = current_level + .time_limit_ms + .saturating_sub(effective_elapsed_ms); + if current_level.remaining_ms == 0 { + current_level.status = PuzzleRuntimeLevelStatus::Failed; + current_level.elapsed_ms = Some(current_level.time_limit_ms); + current_level.pause_started_at_ms = None; + current_level.freeze_started_at_ms = None; + current_level.freeze_until_ms = None; + } + run +} + +pub fn resolve_puzzle_run_timer(run: PuzzleRunSnapshot) -> PuzzleRunSnapshot { + resolve_puzzle_run_timer_at(run, current_unix_ms()) +} + +pub fn set_puzzle_run_paused_at( + run: &PuzzleRunSnapshot, + paused: bool, + now_ms: u64, +) -> Result { + let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); + let current_level = next_run + .current_level + .as_mut() + .ok_or(PuzzleFieldError::InvalidOperation)?; + if current_level.status != PuzzleRuntimeLevelStatus::Playing { + return Ok(next_run); + } + if paused { + if current_level.pause_started_at_ms.is_none() { + current_level.pause_started_at_ms = Some(now_ms); + } + return Ok(next_run); + } + close_level_pause(current_level, now_ms); + Ok(resolve_puzzle_run_timer_at(next_run, now_ms)) +} + +pub fn set_puzzle_run_paused( + run: &PuzzleRunSnapshot, + paused: bool, +) -> Result { + set_puzzle_run_paused_at(run, paused, current_unix_ms()) +} + +pub fn apply_puzzle_freeze_time_at( + run: &PuzzleRunSnapshot, + now_ms: u64, +) -> Result { + let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); + let current_level = next_run + .current_level + .as_mut() + .ok_or(PuzzleFieldError::InvalidOperation)?; + if current_level.status != PuzzleRuntimeLevelStatus::Playing { + return Err(PuzzleFieldError::InvalidOperation); + } + close_level_pause(current_level, now_ms); + current_level.freeze_started_at_ms = Some(now_ms); + current_level.freeze_until_ms = Some(now_ms.saturating_add(PUZZLE_FREEZE_TIME_DURATION_MS)); + Ok(next_run) +} + +pub fn apply_puzzle_freeze_time( + run: &PuzzleRunSnapshot, +) -> Result { + apply_puzzle_freeze_time_at(run, current_unix_ms()) +} + +pub fn extend_failed_puzzle_time_at( + run: &PuzzleRunSnapshot, + now_ms: u64, +) -> Result { + let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); + let current_level = next_run + .current_level + .as_mut() + .ok_or(PuzzleFieldError::InvalidOperation)?; + if current_level.status != PuzzleRuntimeLevelStatus::Failed { + return Err(PuzzleFieldError::InvalidOperation); + } + + let total_consumed_before_extend = current_level + .time_limit_ms + .saturating_sub(PUZZLE_EXTEND_TIME_DURATION_MS); + current_level.status = PuzzleRuntimeLevelStatus::Playing; + current_level.elapsed_ms = None; + current_level.cleared_at_ms = None; + current_level.remaining_ms = PUZZLE_EXTEND_TIME_DURATION_MS; + current_level.started_at_ms = now_ms.saturating_sub(total_consumed_before_extend); + current_level.paused_accumulated_ms = 0; + current_level.pause_started_at_ms = None; + current_level.freeze_accumulated_ms = 0; + current_level.freeze_started_at_ms = None; + current_level.freeze_until_ms = None; + + Ok(next_run) +} + +pub fn extend_failed_puzzle_time( + run: &PuzzleRunSnapshot, +) -> Result { + extend_failed_puzzle_time_at(run, current_unix_ms()) } pub fn build_initial_board(grid_size: u32) -> Result { @@ -371,7 +975,7 @@ pub fn build_initial_board_with_seed( grid_size: u32, shuffle_seed: u64, ) -> Result { - if !matches!(grid_size, 3 | 4) { + if !is_supported_puzzle_grid_size(grid_size) { return Err(PuzzleFieldError::InvalidGridSize); } @@ -384,6 +988,20 @@ pub fn start_run( run_id: String, entry_profile: &PuzzleWorkProfile, cleared_level_count: u32, +) -> Result { + start_run_at( + run_id, + entry_profile, + cleared_level_count, + current_unix_ms(), + ) +} + +pub fn start_run_at( + run_id: String, + entry_profile: &PuzzleWorkProfile, + cleared_level_count: u32, + started_at_ms: u64, ) -> Result { let grid_size = resolve_puzzle_grid_size(cleared_level_count); let shuffle_seed = puzzle_shuffle_seed( @@ -392,7 +1010,13 @@ pub fn start_run( cleared_level_count + 1, grid_size, ); - start_run_with_shuffle_seed(run_id, entry_profile, cleared_level_count, shuffle_seed) + start_run_with_shuffle_seed_at( + run_id, + entry_profile, + cleared_level_count, + shuffle_seed, + started_at_ms, + ) } pub fn start_run_with_shuffle_seed( @@ -401,20 +1025,41 @@ pub fn start_run_with_shuffle_seed( cleared_level_count: u32, shuffle_seed: u64, ) -> Result { - let grid_size = resolve_puzzle_grid_size(cleared_level_count); + start_run_with_shuffle_seed_at( + run_id, + entry_profile, + cleared_level_count, + shuffle_seed, + current_unix_ms(), + ) +} + +pub fn start_run_with_shuffle_seed_at( + run_id: String, + entry_profile: &PuzzleWorkProfile, + cleared_level_count: u32, + shuffle_seed: u64, + started_at_ms: u64, +) -> Result { + let level_index = cleared_level_count + 1; + let level_config = resolve_puzzle_level_config(level_index); + let grid_size = level_config.grid_size; let board = build_initial_board_with_seed(grid_size, shuffle_seed)?; - let started_at_ms = current_unix_ms(); Ok(PuzzleRunSnapshot { run_id: run_id.clone(), entry_profile_id: entry_profile.profile_id.clone(), cleared_level_count, - current_level_index: cleared_level_count + 1, + current_level_index: level_index, current_grid_size: grid_size, played_profile_ids: vec![entry_profile.profile_id.clone()], previous_level_tags: entry_profile.theme_tags.clone(), current_level: Some(PuzzleRuntimeLevelSnapshot { run_id, - level_index: cleared_level_count + 1, + level_index, + level_id: entry_profile + .levels + .first() + .map(|level| level.level_id.clone()), grid_size, profile_id: entry_profile.profile_id.clone(), level_name: entry_profile.level_name.clone(), @@ -426,9 +1071,20 @@ pub fn start_run_with_shuffle_seed( started_at_ms, cleared_at_ms: None, elapsed_ms: None, + time_limit_ms: level_config.time_limit_ms, + remaining_ms: level_config.time_limit_ms, + paused_accumulated_ms: 0, + pause_started_at_ms: None, + freeze_accumulated_ms: 0, + freeze_started_at_ms: None, + freeze_until_ms: None, leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, + next_level_mode: default_puzzle_next_level_mode(), + next_level_profile_id: None, + next_level_id: None, + recommended_next_works: Vec::new(), leaderboard_entries: Vec::new(), }) } @@ -437,16 +1093,26 @@ pub fn swap_pieces( run: &PuzzleRunSnapshot, first_piece_id: &str, second_piece_id: &str, +) -> Result { + swap_pieces_at(run, first_piece_id, second_piece_id, current_unix_ms()) +} + +pub fn swap_pieces_at( + run: &PuzzleRunSnapshot, + first_piece_id: &str, + second_piece_id: &str, + now_ms: u64, ) -> Result { let first_piece_id = normalize_required_string(first_piece_id).ok_or(PuzzleFieldError::MissingPieceId)?; let second_piece_id = normalize_required_string(second_piece_id).ok_or(PuzzleFieldError::MissingPieceId)?; - let current_level = run + let timed_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); + let current_level = timed_run .current_level .clone() .ok_or(PuzzleFieldError::InvalidOperation)?; - if current_level.status == PuzzleRuntimeLevelStatus::Cleared { + if current_level.status != PuzzleRuntimeLevelStatus::Playing { return Err(PuzzleFieldError::InvalidOperation); } let mut pieces = current_level.board.pieces.clone(); @@ -489,7 +1155,7 @@ pub fn swap_pieces( affected_cells, None, ); - Ok(with_next_board(run, next_board)) + Ok(with_next_board_at(&timed_run, next_board, now_ms)) } pub fn drag_piece_or_group( @@ -497,13 +1163,24 @@ pub fn drag_piece_or_group( piece_id: &str, target_row: u32, target_col: u32, +) -> Result { + drag_piece_or_group_at(run, piece_id, target_row, target_col, current_unix_ms()) +} + +pub fn drag_piece_or_group_at( + run: &PuzzleRunSnapshot, + piece_id: &str, + target_row: u32, + target_col: u32, + now_ms: u64, ) -> Result { let piece_id = normalize_required_string(piece_id).ok_or(PuzzleFieldError::MissingPieceId)?; - let current_level = run + let timed_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); + let current_level = timed_run .current_level .clone() .ok_or(PuzzleFieldError::InvalidOperation)?; - if current_level.status == PuzzleRuntimeLevelStatus::Cleared { + if current_level.status != PuzzleRuntimeLevelStatus::Playing { return Err(PuzzleFieldError::InvalidOperation); } let grid_size = current_level.grid_size; @@ -530,7 +1207,7 @@ pub fn drag_piece_or_group( operation_cells, None, ); - Ok(with_next_board(run, next_board)) + Ok(with_next_board_at(&timed_run, next_board, now_ms)) } pub fn rebuild_board_snapshot_for_affected_cells( @@ -608,6 +1285,14 @@ pub fn rebuild_board_snapshot_for_affected_cells( pub fn advance_next_level( run: &PuzzleRunSnapshot, next_profile: &PuzzleWorkProfile, +) -> Result { + advance_next_level_at(run, next_profile, current_unix_ms()) +} + +pub fn advance_next_level_at( + run: &PuzzleRunSnapshot, + next_profile: &PuzzleWorkProfile, + started_at_ms: u64, ) -> Result { let current_level = run .current_level @@ -618,11 +1303,13 @@ pub fn advance_next_level( } let next_cleared_count = run.cleared_level_count; - let next_grid_size = resolve_puzzle_grid_size(next_cleared_count); + let next_level_index = run.current_level_index + 1; + let next_level_config = resolve_puzzle_level_config(next_level_index); + let next_grid_size = next_level_config.grid_size; let shuffle_seed = puzzle_shuffle_seed( &run.run_id, &next_profile.profile_id, - run.current_level_index + 1, + next_level_index, next_grid_size, ); let next_board = build_initial_board_with_seed(next_grid_size, shuffle_seed)?; @@ -633,13 +1320,17 @@ pub fn advance_next_level( run_id: run.run_id.clone(), entry_profile_id: run.entry_profile_id.clone(), cleared_level_count: next_cleared_count, - current_level_index: run.current_level_index + 1, + current_level_index: next_level_index, current_grid_size: next_grid_size, played_profile_ids, previous_level_tags: next_profile.theme_tags.clone(), current_level: Some(PuzzleRuntimeLevelSnapshot { run_id: run.run_id.clone(), - level_index: run.current_level_index + 1, + level_index: next_level_index, + level_id: next_profile + .levels + .first() + .map(|level| level.level_id.clone()), grid_size: next_grid_size, profile_id: next_profile.profile_id.clone(), level_name: next_profile.level_name.clone(), @@ -648,21 +1339,189 @@ pub fn advance_next_level( cover_image_src: next_profile.cover_image_src.clone(), board: next_board, status: PuzzleRuntimeLevelStatus::Playing, - started_at_ms: current_unix_ms(), + started_at_ms, cleared_at_ms: None, elapsed_ms: None, + time_limit_ms: next_level_config.time_limit_ms, + remaining_ms: next_level_config.time_limit_ms, + paused_accumulated_ms: 0, + pause_started_at_ms: None, + freeze_accumulated_ms: 0, + freeze_started_at_ms: None, + freeze_until_ms: None, leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, + next_level_mode: default_puzzle_next_level_mode(), + next_level_profile_id: None, + next_level_id: None, + recommended_next_works: Vec::new(), leaderboard_entries: Vec::new(), }) } +pub fn advance_to_new_work_first_level_at( + run: &PuzzleRunSnapshot, + next_profile: &PuzzleWorkProfile, + started_at_ms: u64, +) -> Result { + let current_level = run + .current_level + .clone() + .ok_or(PuzzleFieldError::InvalidOperation)?; + if current_level.status != PuzzleRuntimeLevelStatus::Cleared { + return Err(PuzzleFieldError::InvalidOperation); + } + + // 中文注释:跨作品代表进入一个新作品,关卡序号、切割规格和倒计时都从第 1 关重新开始。 + let next_level_index = 1; + let level_config = resolve_puzzle_level_config(next_level_index); + let grid_size = level_config.grid_size; + let shuffle_seed = puzzle_shuffle_seed( + &run.run_id, + &next_profile.profile_id, + next_level_index, + grid_size, + ); + let next_board = build_initial_board_with_seed(grid_size, shuffle_seed)?; + let mut played_profile_ids = run.played_profile_ids.clone(); + if !played_profile_ids.contains(&next_profile.profile_id) { + played_profile_ids.push(next_profile.profile_id.clone()); + } + + Ok(PuzzleRunSnapshot { + run_id: run.run_id.clone(), + entry_profile_id: next_profile.profile_id.clone(), + cleared_level_count: 0, + current_level_index: next_level_index, + current_grid_size: grid_size, + played_profile_ids, + previous_level_tags: next_profile.theme_tags.clone(), + current_level: Some(PuzzleRuntimeLevelSnapshot { + run_id: run.run_id.clone(), + level_index: next_level_index, + level_id: next_profile + .levels + .first() + .map(|level| level.level_id.clone()), + grid_size, + profile_id: next_profile.profile_id.clone(), + level_name: next_profile.level_name.clone(), + author_display_name: next_profile.author_display_name.clone(), + theme_tags: next_profile.theme_tags.clone(), + cover_image_src: next_profile.cover_image_src.clone(), + board: next_board, + status: PuzzleRuntimeLevelStatus::Playing, + started_at_ms, + cleared_at_ms: None, + elapsed_ms: None, + time_limit_ms: level_config.time_limit_ms, + remaining_ms: level_config.time_limit_ms, + paused_accumulated_ms: 0, + pause_started_at_ms: None, + freeze_accumulated_ms: 0, + freeze_started_at_ms: None, + freeze_until_ms: None, + leaderboard_entries: Vec::new(), + }), + recommended_next_profile_id: None, + next_level_mode: default_puzzle_next_level_mode(), + next_level_profile_id: None, + next_level_id: None, + recommended_next_works: Vec::new(), + leaderboard_entries: Vec::new(), + }) +} + +pub fn selected_profile_level_after_index( + profile: &PuzzleWorkProfile, + current_level_index: u32, +) -> Option { + if current_level_index == 0 { + return None; + } + let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags) + .unwrap_or_else(|_| profile.levels.clone()); + normalized_levels.get(current_level_index as usize).cloned() +} + +pub fn selected_profile_level_after_runtime_level( + profile: &PuzzleWorkProfile, + current_level: &PuzzleRuntimeLevelSnapshot, +) -> Option { + let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags) + .unwrap_or_else(|_| profile.levels.clone()); + if normalized_levels.len() <= 1 { + return None; + } + + let matched_index = current_level + .level_id + .as_ref() + .and_then(|level_id| { + normalized_levels + .iter() + .position(|level| level.level_id == *level_id) + }) + .or_else(|| { + current_level + .cover_image_src + .as_ref() + .and_then(|cover_image_src| { + normalized_levels.iter().position(|level| { + level.cover_image_src.as_ref() == Some(cover_image_src) + && level.level_name == current_level.level_name + }) + }) + }) + .or_else(|| { + normalized_levels.iter().position(|level| { + level.level_name == current_level.level_name + && level.cover_image_src == current_level.cover_image_src + }) + }) + .or_else(|| { + current_level.level_index.checked_sub(1).and_then(|index| { + ((index as usize) < normalized_levels.len()).then_some(index as usize) + }) + })?; + + normalized_levels.get(matched_index + 1).cloned() +} + +pub fn selected_profile_level_index(profile: &PuzzleWorkProfile, level_id: &str) -> Option { + let target_level_id = normalize_required_string(level_id)?; + let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags) + .unwrap_or_else(|_| profile.levels.clone()); + normalized_levels + .iter() + .position(|level| level.level_id == target_level_id) +} + +pub fn resolve_restart_cleared_level_count(profile: &PuzzleWorkProfile, level_id: &str) -> u32 { + // 中文注释:失败重开指定的是当前关 levelId;start_run_at 用“已通关数 + 1”计算当前关,所以这里返回关卡下标。 + selected_profile_level_index(profile, level_id).unwrap_or(0) as u32 +} + pub fn select_next_profile<'a>( current_profile: &PuzzleWorkProfile, played_profile_ids: &[String], candidates: &'a [PuzzleWorkProfile], ) -> Option<&'a PuzzleWorkProfile> { + select_next_profiles(current_profile, played_profile_ids, candidates, 1) + .into_iter() + .next() +} + +pub fn select_next_profiles<'a>( + current_profile: &PuzzleWorkProfile, + played_profile_ids: &[String], + candidates: &'a [PuzzleWorkProfile], + limit: usize, +) -> Vec<&'a PuzzleWorkProfile> { + if limit == 0 { + return Vec::new(); + } let mut available = candidates .iter() .filter(|candidate| { @@ -683,23 +1542,25 @@ pub fn select_next_profile<'a>( available.retain(|candidate| candidate.profile_id != *last_played); } - available.into_iter().max_by(|left, right| { + available.sort_by(|left, right| { let left_score = recommendation_score(current_profile, left); let right_score = recommendation_score(current_profile, right); - left_score - .partial_cmp(&right_score) + right_score + .partial_cmp(&left_score) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| { - tag_similarity_score(¤t_profile.theme_tags, &left.theme_tags) + tag_similarity_score(¤t_profile.theme_tags, &right.theme_tags) .partial_cmp(&tag_similarity_score( ¤t_profile.theme_tags, - &right.theme_tags, + &left.theme_tags, )) .unwrap_or(std::cmp::Ordering::Equal) }) - .then_with(|| right.play_count.cmp(&left.play_count)) - .then_with(|| left.updated_at_micros.cmp(&right.updated_at_micros)) - }) + .then_with(|| left.play_count.cmp(&right.play_count)) + .then_with(|| right.updated_at_micros.cmp(&left.updated_at_micros)) + }); + available.truncate(limit); + available } pub fn recommendation_score( @@ -730,10 +1591,169 @@ pub fn tag_similarity_score(left_tags: &[String], right_tags: &[String]) -> f32 if union <= f32::EPSILON { 0.0 } else { - intersection / union + let lexical_score = intersection / union; + // 中文注释:优先复用 RPG build 标签的属性亲和度语义模型;拼图自有标签未命中时保留 Jaccard 兜底。 + rpg_build_tag_set_similarity(&left_set, &right_set) + .map(|semantic_score| semantic_score.max(lexical_score)) + .unwrap_or(lexical_score) } } +#[derive(Clone, Copy)] +struct RpgBuildTagSemanticDefinition { + category: &'static str, + affinity: [f32; 6], +} + +fn rpg_affinity(strength: f32, agility: f32, intelligence: f32, spirit: f32) -> [f32; 6] { + [ + strength * 0.72 + spirit * 0.28, + agility * 0.88 + intelligence * 0.12, + intelligence * 0.78 + agility * 0.22, + strength * 0.62 + agility * 0.18 + intelligence * 0.2, + spirit * 0.72 + intelligence * 0.28, + spirit * 0.74 + strength * 0.26, + ] +} + +fn resolve_rpg_build_tag_semantic(tag: &str) -> Option { + let normalized = tag.trim().to_lowercase(); + let value = normalized.as_str(); + let definition = match value { + "quickblade" | "快剑" | "快刀" | "决斗者" => { + ("style", rpg_affinity(0.35, 1.0, 0.1, 0.05)) + } + "combo" | "连段" | "连击" | "连锁" => ("style", rpg_affinity(0.3, 0.92, 0.18, 0.08)), + "dash" | "突进" | "冲锋" => ("style", rpg_affinity(0.45, 0.95, 0.0, 0.0)), + "pursuit" | "追击" => ("style", rpg_affinity(0.38, 0.88, 0.08, 0.02)), + "swiftstrike" | "快袭" | "刺袭" | "伏击" => { + ("style", rpg_affinity(0.22, 0.98, 0.12, 0.04)) + } + "ranged" | "远射" | "射击" | "箭矢" => { + ("style", rpg_affinity(0.18, 0.82, 0.34, 0.08)) + } + "guerrilla" | "游击" | "骚扰" => ("style", rpg_affinity(0.24, 0.9, 0.28, 0.12)), + "mobility" | "机动" | "敏捷" | "灵活" => { + ("style", rpg_affinity(0.18, 1.0, 0.08, 0.08)) + } + "windrun" | "风行" | "疾行" => ("style", rpg_affinity(0.08, 1.0, 0.1, 0.1)), + "heavyhit" | "重击" => ("style", rpg_affinity(1.0, 0.28, 0.02, 0.04)), + "burst" | "爆发" => ("style", rpg_affinity(0.72, 0.58, 0.36, 0.08)), + "armorbreak" | "破甲" => ("style", rpg_affinity(0.92, 0.28, 0.08, 0.02)), + "pressure" | "压制" => ("style", rpg_affinity(0.62, 0.64, 0.1, 0.08)), + "bloodrush" | "压血" => ("resource", rpg_affinity(0.84, 0.54, 0.04, 0.18)), + "guard" | "守御" | "守卫" | "防御" => { + ("defense", rpg_affinity(0.7, 0.18, 0.04, 0.72)) + } + "barrier" | "护体" | "护罩" | "护盾" => { + ("defense", rpg_affinity(0.48, 0.08, 0.2, 0.92)) + } + "heavyarmor" | "重甲" => ("defense", rpg_affinity(0.88, 0.04, 0.02, 0.54)), + "counter" | "反击" | "回击" => ("defense", rpg_affinity(0.66, 0.46, 0.14, 0.36)), + "banish" | "镇邪" => ("defense", rpg_affinity(0.24, 0.06, 0.54, 0.88)), + "caster" | "法修" | "法师" => ("element", rpg_affinity(0.0, 0.1, 1.0, 0.6)), + "mana" | "法力" => ("resource", rpg_affinity(0.02, 0.08, 0.94, 0.74)), + "thunder" | "雷法" => ("element", rpg_affinity(0.06, 0.24, 0.96, 0.42)), + "formation" | "符阵" | "法阵" => ("element", rpg_affinity(0.08, 0.12, 0.82, 0.96)), + "control" | "控场" | "控制" => ("style", rpg_affinity(0.12, 0.34, 0.78, 0.72)), + "overload" | "过载" => ("resource", rpg_affinity(0.14, 0.18, 0.92, 0.38)), + "heal" | "回复" | "治疗" => ("resource", rpg_affinity(0.02, 0.08, 0.56, 1.0)), + "support" | "护持" | "支援" | "祝福" => { + ("resource", rpg_affinity(0.14, 0.14, 0.58, 0.98)) + } + "sustain" | "续战" => ("resource", rpg_affinity(0.34, 0.18, 0.22, 0.9)), + "fate" | "命纹" => ("flow", rpg_affinity(0.08, 0.22, 0.72, 0.84)), + "fortune" | "机缘" => ("flow", rpg_affinity(0.06, 0.34, 0.7, 0.78)), + "cooldown" | "冷却" => ("resource", rpg_affinity(0.04, 0.46, 0.82, 0.4)), + "command" | "统御" => ("flow", rpg_affinity(0.38, 0.26, 0.72, 0.82)), + "balanced" | "均衡" | "平衡" | "全能" => { + ("flow", rpg_affinity(0.58, 0.58, 0.58, 0.58)) + } + "craft" | "工巧" | "工艺" => ("craft", rpg_affinity(0.24, 0.16, 0.74, 0.5)), + "alchemy" | "炼药" | "药剂" => ("craft", rpg_affinity(0.08, 0.16, 0.84, 0.76)), + "vanguard" | "先锋" => ("flow", rpg_affinity(0.82, 0.44, 0.08, 0.34)), + "berserk" | "狂战" => ("flow", rpg_affinity(0.98, 0.42, 0.0, 0.22)), + "spellblade" | "法剑" => ("flow", rpg_affinity(0.42, 0.42, 0.88, 0.38)), + "paladin" | "圣佑" | "圣骑士" => ("flow", rpg_affinity(0.58, 0.12, 0.42, 0.96)), + "fortress" | "堡垒" => ("flow", rpg_affinity(0.94, 0.04, 0.08, 0.82)), + "starter" | "起手" => ("flow", rpg_affinity(0.42, 0.42, 0.42, 0.42)), + _ => return None, + }; + Some(RpgBuildTagSemanticDefinition { + category: definition.0, + affinity: definition.1, + }) +} + +fn normalized_affinity_dot(left: [f32; 6], right: [f32; 6]) -> f32 { + let left_magnitude = left.iter().map(|value| value * value).sum::().sqrt(); + let right_magnitude = right.iter().map(|value| value * value).sum::().sqrt(); + if left_magnitude <= 0.0001 || right_magnitude <= 0.0001 { + return 0.0; + } + left.iter() + .zip(right.iter()) + .map(|(left_value, right_value)| { + (left_value / left_magnitude) * (right_value / right_magnitude) + }) + .sum::() +} + +fn rpg_build_tag_similarity( + left: RpgBuildTagSemanticDefinition, + right: RpgBuildTagSemanticDefinition, +) -> f32 { + let category_bonus = if left.category == right.category { + 0.08 + } else { + 0.0 + }; + (normalized_affinity_dot(left.affinity, right.affinity) + category_bonus).min(1.0) +} + +fn rpg_build_tag_directional_similarity( + left: &[RpgBuildTagSemanticDefinition], + right: &[RpgBuildTagSemanticDefinition], +) -> f32 { + if left.is_empty() || right.is_empty() { + return 0.0; + } + let total = left + .iter() + .map(|left_definition| { + right + .iter() + .map(|right_definition| { + rpg_build_tag_similarity(*left_definition, *right_definition) + }) + .fold(0.0_f32, f32::max) + }) + .sum::(); + total / left.len() as f32 +} + +fn rpg_build_tag_set_similarity( + left_tags: &BTreeSet, + right_tags: &BTreeSet, +) -> Option { + let left_definitions = left_tags + .iter() + .filter_map(|tag| resolve_rpg_build_tag_semantic(tag)) + .collect::>(); + let right_definitions = right_tags + .iter() + .filter_map(|tag| resolve_rpg_build_tag_semantic(tag)) + .collect::>(); + if left_definitions.is_empty() || right_definitions.is_empty() { + return None; + } + Some( + (rpg_build_tag_directional_similarity(&left_definitions, &right_definitions) + + rpg_build_tag_directional_similarity(&right_definitions, &left_definitions)) + / 2.0, + ) +} + pub fn normalize_theme_tags(tags: Vec) -> Vec { let alias_map = BTreeMap::from([ ("蒸汽", "蒸汽城市"), @@ -815,6 +1835,145 @@ fn infer_tags_and_forbidden(source: &str) -> String { } } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct PuzzleFormSeedParts { + work_title: Option, + work_description: Option, + picture_description: Option, +} + +impl PuzzleFormSeedParts { + fn has_any_value(&self) -> bool { + self.work_title.is_some() + || self.work_description.is_some() + || self.picture_description.is_some() + } +} + +fn parse_form_seed_text(source: &str) -> Option { + let normalized_source = source.trim(); + if normalized_source.is_empty() { + return None; + } + + let title_marker = if normalized_source.contains("作品名称:") { + "作品名称:" + } else { + "拼图标题:" + }; + let parts = PuzzleFormSeedParts { + work_title: extract_form_seed_value(normalized_source, title_marker), + work_description: extract_form_seed_value(normalized_source, "作品描述:"), + picture_description: extract_form_seed_value(normalized_source, "画面描述:"), + }; + + parts.has_any_value().then_some(parts) +} + +fn extract_form_seed_value(source: &str, marker: &str) -> Option { + let value_start = source.find(marker)? + marker.len(); + let value_end = ["作品名称:", "拼图标题:", "作品描述:", "画面描述:"] + .into_iter() + .filter(|next_marker| *next_marker != marker) + .filter_map(|next_marker| { + source[value_start..] + .find(next_marker) + .map(|index| value_start + index) + }) + .min() + .unwrap_or(source.len()); + normalize_required_string(&source[value_start..value_end]) +} + +fn build_form_tags_and_forbidden(title: &str, picture_description: &str) -> String { + let mut tags = derive_form_theme_tags(title, picture_description); + if tags.len() < PUZZLE_MIN_TAG_COUNT { + for fallback in ["拼图", "插画", "清晰构图"] { + if !tags.iter().any(|tag| tag == fallback) { + tags.push(fallback.to_string()); + } + if tags.len() >= PUZZLE_MIN_TAG_COUNT { + break; + } + } + } + format!("{};禁止标题字", tags.join("、")) +} + +fn derive_form_theme_tags(title: &str, picture_description: &str) -> Vec { + let source = format!("{title} {picture_description}"); + let keyword_tags = [ + ("猫", "猫咪"), + ("狗", "小狗"), + ("神庙", "神庙遗迹"), + ("遗迹", "神庙遗迹"), + ("森林", "童话森林"), + ("雨", "雨夜"), + ("夜", "夜景"), + ("城市", "城市奇景"), + ("蒸汽", "蒸汽城市"), + ("机械", "机械幻想"), + ("海", "海岸"), + ("花", "花园"), + ("雪", "雪景"), + ("龙", "幻想生物"), + ("灯", "暖灯"), + ]; + let mut tags = keyword_tags + .into_iter() + .filter(|(keyword, _)| source.contains(keyword)) + .map(|(_, tag)| tag.to_string()) + .collect::>(); + + for value in title + .split(|ch: char| ch.is_whitespace() || matches!(ch, ',' | '、' | ',' | ';' | ';')) + .filter_map(normalize_required_string) + { + if value.chars().count() <= 8 { + tags.push(value); + } + } + + normalize_theme_tags(tags) +} + +fn is_form_anchor_pack(anchor_pack: &PuzzleAnchorPack) -> bool { + matches!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked) + || matches!( + anchor_pack.visual_subject.status, + PuzzleAnchorStatus::Locked + ) +} + +fn build_result_summary(anchor_pack: &PuzzleAnchorPack) -> String { + if is_form_anchor_pack(anchor_pack) { + return fallback_text(&anchor_pack.visual_subject.value, "画面主体"); + } + + format!( + "{},主体是{},氛围偏{}。", + fallback_text(&anchor_pack.theme_promise.value, "梦幻题材"), + fallback_text(&anchor_pack.visual_subject.value, "画面主体"), + fallback_text(&anchor_pack.visual_mood.value, "温暖") + ) +} + +fn resolve_work_description(seed_text: Option<&str>, anchor_pack: &PuzzleAnchorPack) -> String { + seed_text + .and_then(parse_form_seed_text) + .and_then(|parts| { + parts + .work_description + .or(parts.picture_description) + .or(parts.work_title) + }) + .unwrap_or_else(|| build_result_summary(anchor_pack)) +} + +fn build_work_title(anchor_pack: &PuzzleAnchorPack) -> String { + fallback_text(&anchor_pack.theme_promise.value, "奇景拼图") +} + fn extract_forbidden_directive(source: &str) -> String { if let Some((_, tail)) = source.split_once(';') { return normalize_required_string(tail).unwrap_or_else(|| "禁止标题字".to_string()); @@ -822,14 +1981,24 @@ fn extract_forbidden_directive(source: &str) -> String { "禁止标题字".to_string() } -fn build_level_name(anchor_pack: &PuzzleAnchorPack, normalized_tags: &[String]) -> String { +fn build_level_name_from_picture( + picture_description: &str, + normalized_tags: &[String], + level_index: usize, +) -> String { + let source = normalize_required_string(picture_description).unwrap_or_default(); + for keyword in [ + "猫", "狗", "神庙", "遗迹", "森林", "雨夜", "城市", "机械", "海", "花", "雪", "龙", "灯", + "塔", + ] { + if source.contains(keyword) { + return format!("{keyword}画面"); + } + } if let Some(tag) = normalized_tags.first() { - return format!("{tag}拼图"); + return format!("{tag}第{level_index}关"); } - if let Some(subject) = normalize_required_string(&anchor_pack.visual_subject.value) { - return subject.chars().take(8).collect::(); - } - "奇景拼图".to_string() + format!("第{level_index}关") } fn fallback_text(value: &str, fallback: &str) -> String { @@ -894,7 +2063,8 @@ fn build_initial_pieces_without_correct_neighbors( } // 随机尝试耗尽后使用确定性约束搜索兜底,保证开局没有任意一对原图相邻块互相贴边。 - let fallback_pieces = build_original_neighbor_free_pieces(grid_size, shuffle_seed) + let fallback_pieces = build_deterministic_neighbor_free_pieces(grid_size, shuffle_seed) + .or_else(|| build_original_neighbor_free_pieces(grid_size, shuffle_seed)) .unwrap_or_else(|| build_pieces_from_positions(grid_size, &base_positions)); debug_assert!(!has_any_original_neighbor_pair(&fallback_pieces)); fallback_pieces @@ -962,6 +2132,124 @@ fn are_original_neighbors(left: &PuzzlePieceState, right: &PuzzlePieceState) -> left.correct_row.abs_diff(right.correct_row) + left.correct_col.abs_diff(right.correct_col) == 1 } +fn build_deterministic_neighbor_free_pieces( + grid_size: u32, + shuffle_seed: u64, +) -> Option> { + // 中文注释:大棋盘随机命中“无原图相邻贴边”的概率较低,失败后用确定性排列兜底保证稳定开局。 + let positions = match grid_size { + 3 => build_seeded_3x3_neighbor_free_positions(shuffle_seed), + 4 | 6 => build_affine_neighbor_free_positions(grid_size, 1, 1, 2, 1, shuffle_seed), + 5 | 7 => { + build_affine_neighbor_free_positions(grid_size, 0, 1, 2, grid_size - 1, shuffle_seed) + } + _ => return None, + }; + let pieces = build_pieces_from_positions(grid_size, &positions); + (!has_any_original_neighbor_pair(&pieces)).then_some(pieces) +} + +fn build_seeded_3x3_neighbor_free_positions(shuffle_seed: u64) -> Vec { + const LAYOUTS: [[(u32, u32); 9]; 6] = [ + [ + (0, 1), + (1, 0), + (1, 2), + (2, 0), + (0, 2), + (2, 1), + (1, 1), + (2, 2), + (0, 0), + ], + [ + (0, 1), + (1, 0), + (1, 2), + (2, 0), + (0, 2), + (2, 1), + (2, 2), + (1, 1), + (0, 0), + ], + [ + (0, 1), + (1, 0), + (1, 2), + (2, 0), + (2, 2), + (0, 0), + (1, 1), + (0, 2), + (2, 1), + ], + [ + (0, 1), + (1, 0), + (1, 2), + (2, 1), + (0, 2), + (2, 0), + (0, 0), + (2, 2), + (1, 1), + ], + [ + (0, 1), + (1, 0), + (1, 2), + (2, 2), + (0, 2), + (2, 1), + (1, 1), + (2, 0), + (0, 0), + ], + [ + (0, 1), + (1, 0), + (2, 1), + (2, 0), + (2, 2), + (0, 2), + (1, 2), + (0, 0), + (1, 1), + ], + ]; + let layout = &LAYOUTS[(shuffle_seed as usize) % LAYOUTS.len()]; + layout + .into_iter() + .map(|(row, col)| PuzzleCellPosition { + row: *row, + col: *col, + }) + .collect() +} + +fn build_affine_neighbor_free_positions( + grid_size: u32, + row_from_row: u32, + row_from_col: u32, + col_from_row: u32, + col_from_col: u32, + shuffle_seed: u64, +) -> Vec { + let row_offset = (shuffle_seed % u64::from(grid_size)) as u32; + let col_offset = ((shuffle_seed / u64::from(grid_size)) % u64::from(grid_size)) as u32; + (0..(grid_size * grid_size)) + .map(|index| { + let row = index / grid_size; + let col = index % grid_size; + PuzzleCellPosition { + row: (row_from_row * row + row_from_col * col + row_offset) % grid_size, + col: (col_from_row * row + col_from_col * col + col_offset) % grid_size, + } + }) + .collect() +} + fn build_original_neighbor_free_pieces( grid_size: u32, shuffle_seed: u64, @@ -1403,7 +2691,11 @@ fn drag_group( Ok(affected_cells) } -fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> PuzzleRunSnapshot { +fn with_next_board_at( + run: &PuzzleRunSnapshot, + next_board: PuzzleBoardSnapshot, + now_ms: u64, +) -> PuzzleRunSnapshot { let mut next_run = run.clone(); let is_cleared = next_board.all_tiles_resolved; let next_level_status = if is_cleared { @@ -1415,13 +2707,10 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> if let Some(current_level) = next_run.current_level.as_mut() { current_level.board = next_board; if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared { - let cleared_at_ms = current_unix_ms(); - current_level.cleared_at_ms = Some(cleared_at_ms); - current_level.elapsed_ms = Some( - cleared_at_ms - .saturating_sub(current_level.started_at_ms) - .max(1_000), - ); + current_level.cleared_at_ms = Some(now_ms); + current_level.elapsed_ms = + Some(resolve_effective_elapsed_ms(current_level, now_ms).max(1_000)); + current_level.remaining_ms = 0; } current_level.status = next_level_status; } @@ -1444,6 +2733,20 @@ fn current_unix_ms() -> u64 { .unwrap_or(0) } +pub fn current_puzzle_unix_micros() -> i64 { + (current_unix_ms() as i64).saturating_mul(1_000) +} + +pub fn puzzle_point_incentive_claimable_points(total_half_points: u64, claimed_points: u64) -> u64 { + total_half_points + .saturating_div(2) + .saturating_sub(claimed_points) +} + +pub fn puzzle_point_incentive_total_after_spend(total_half_points: u64, spent_points: u64) -> u64 { + total_half_points.saturating_add(spent_points) +} + #[cfg(test)] mod tests { use super::*; @@ -1459,15 +2762,32 @@ mod tests { owner_user_id: owner_user_id.to_string(), source_session_id: None, author_display_name: "作者".to_string(), + work_title: format!("{profile_id} 作品"), + work_description: "summary".to_string(), level_name: format!("{profile_id} 关"), summary: "summary".to_string(), theme_tags: tags.into_iter().map(|value| value.to_string()).collect(), cover_image_src: Some("/cover.png".to_string()), cover_asset_id: Some("asset-1".to_string()), + levels: vec![PuzzleDraftLevel { + level_id: "puzzle-level-1".to_string(), + level_name: format!("{profile_id} 关"), + picture_description: "summary".to_string(), + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: Some("/cover.png".to_string()), + cover_asset_id: Some("asset-1".to_string()), + generation_status: "ready".to_string(), + }], publication_status: PuzzlePublicationStatus::Published, updated_at_micros: 100, published_at_micros: Some(100), play_count: 0, + recent_play_count_7d: 0, + remix_count: 0, + like_count: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, publish_ready: true, anchor_pack: empty_anchor_pack(), } @@ -1476,8 +2796,51 @@ mod tests { #[test] fn resolve_grid_size_matches_prd() { assert_eq!(resolve_puzzle_grid_size(0), 3); - assert_eq!(resolve_puzzle_grid_size(2), 3); - assert_eq!(resolve_puzzle_grid_size(3), 4); + assert_eq!(resolve_puzzle_grid_size(1), 4); + assert_eq!(resolve_puzzle_grid_size(2), 5); + assert_eq!(resolve_puzzle_grid_size(3), 5); + assert_eq!(resolve_puzzle_grid_size(4), 5); + assert_eq!(resolve_puzzle_grid_size(5), 6); + assert_eq!(resolve_puzzle_grid_size(6), 5); + assert_eq!(resolve_puzzle_grid_size(7), 7); + assert_eq!(resolve_puzzle_grid_size(8), 5); + assert_eq!(resolve_puzzle_grid_size(9), 7); + assert_eq!(resolve_puzzle_grid_size(10), 5); + assert_eq!(resolve_puzzle_grid_size(15), 7); + } + + #[test] + fn resolve_level_time_limit_matches_prd() { + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(1), 300_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(2), 300_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(3), 300_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(4), 210_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(5), 210_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(6), 240_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(7), 210_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(8), 270_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(9), 240_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(10), 270_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(11), 210_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(16), 270_000); + } + + #[test] + fn form_draft_preserves_partial_initial_fields() { + let seed_text = "作品名称:月台拼图\n作品描述:"; + let anchor_pack = infer_anchor_pack(seed_text, Some(seed_text)); + let draft = build_form_draft_from_seed(&anchor_pack, Some(seed_text)); + let form_draft = draft.form_draft.expect("form draft should exist"); + + assert_eq!(form_draft.work_title.as_deref(), Some("月台拼图")); + assert_eq!(form_draft.work_description, None); + assert_eq!(form_draft.picture_description, None); + assert_eq!(draft.work_title, "月台拼图"); + assert_eq!(draft.work_description, ""); + assert_eq!(draft.level_name, ""); + assert_eq!(draft.levels[0].level_name, ""); + assert_eq!(draft.anchor_pack.theme_promise.value, "月台拼图"); + assert_eq!(draft.anchor_pack.visual_subject.value, ""); } #[test] @@ -1511,7 +2874,67 @@ mod tests { } #[test] - fn tag_similarity_score_uses_jaccard() { + fn form_seed_locks_title_and_picture_description_as_primary_anchors() { + let anchor_pack = infer_anchor_pack( + "作品名称:暖灯猫街\n作品描述:一套雨夜猫街主题拼图。\n画面描述:一只猫在雨夜灯牌下回头。", + None, + ); + let draft = compile_result_draft_from_seed( + &anchor_pack, + &[], + Some( + "作品名称:暖灯猫街\n作品描述:一套雨夜猫街主题拼图。\n画面描述:一只猫在雨夜灯牌下回头。", + ), + ); + + assert_eq!(anchor_pack.theme_promise.value, "暖灯猫街"); + assert_eq!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked); + assert_eq!(anchor_pack.visual_subject.value, "一只猫在雨夜灯牌下回头。"); + assert_eq!( + anchor_pack.visual_subject.status, + PuzzleAnchorStatus::Locked + ); + assert_eq!(draft.work_title, "暖灯猫街"); + assert_eq!(draft.work_description, "一套雨夜猫街主题拼图。"); + assert_eq!(draft.summary, "一套雨夜猫街主题拼图。"); + assert_eq!(draft.level_name, "猫画面"); + assert_eq!( + draft.levels[0].picture_description, + "一只猫在雨夜灯牌下回头。" + ); + assert_eq!( + draft + .creator_intent + .as_ref() + .map(|intent| intent.source_mode.as_str()), + Some("form") + ); + assert!(draft.theme_tags.len() >= PUZZLE_MIN_TAG_COUNT); + } + + #[test] + fn form_seed_keeps_multiline_picture_description() { + let anchor_pack = infer_anchor_pack( + "拼图标题:雨夜猫街\n画面描述:一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。", + None, + ); + let draft = compile_result_draft(&anchor_pack, &[]); + + assert_eq!( + anchor_pack.visual_subject.value, + "一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。" + ); + assert_eq!( + draft.levels[0].picture_description, + "一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。" + ); + assert_eq!(draft.summary, draft.work_description); + assert!(draft.theme_tags.iter().any(|tag| tag == "猫咪")); + assert!(draft.theme_tags.iter().any(|tag| tag == "雨夜")); + } + + #[test] + fn tag_similarity_score_uses_jaccard_fallback() { let score = tag_similarity_score( &["蒸汽城市".to_string(), "雨夜".to_string()], &["蒸汽城市".to_string(), "猫咪".to_string()], @@ -1519,6 +2942,13 @@ mod tests { assert!((score - 0.3333).abs() < 0.01); } + #[test] + fn tag_similarity_score_prefers_rpg_build_semantic_affinity() { + let score = tag_similarity_score(&["快剑".to_string()], &["连击".to_string()]); + + assert!(score > 0.75); + } + #[test] fn select_next_profile_prefers_same_tags_and_author() { let current = build_published_profile("a", "owner-a", vec!["蒸汽城市", "雨夜"]); @@ -1531,6 +2961,66 @@ mod tests { assert_eq!(selected.profile_id, "b"); } + #[test] + fn restart_cleared_count_uses_selected_level_index() { + let mut profile = build_published_profile("entry", "owner-a", vec!["机关"]); + profile.levels = vec![ + PuzzleDraftLevel { + level_id: "puzzle-level-1".to_string(), + level_name: "第一关".to_string(), + picture_description: "第一关画面".to_string(), + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: Some("/level-1.png".to_string()), + cover_asset_id: None, + generation_status: "ready".to_string(), + }, + PuzzleDraftLevel { + level_id: "puzzle-level-2".to_string(), + level_name: "第二关".to_string(), + picture_description: "第二关画面".to_string(), + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: Some("/level-2.png".to_string()), + cover_asset_id: None, + generation_status: "ready".to_string(), + }, + ]; + + assert_eq!( + resolve_restart_cleared_level_count(&profile, "puzzle-level-2"), + 1 + ); + assert_eq!( + resolve_restart_cleared_level_count(&profile, "missing-level"), + 0 + ); + } + + #[test] + fn advance_to_new_work_first_level_restarts_level_progress() { + let first_profile = build_published_profile("entry", "owner-a", vec!["奇幻", "遗迹"]); + let next_profile = build_published_profile("next", "owner-b", vec!["奇幻", "魔法"]); + let mut run = start_run("run-cross-work".to_string(), &first_profile, 2).expect("run"); + run.cleared_level_count = run.current_level_index; + let current_level = run.current_level.as_mut().expect("level"); + current_level.status = PuzzleRuntimeLevelStatus::Cleared; + current_level.cleared_at_ms = Some(2_000); + current_level.elapsed_ms = Some(1_000); + + let next_run = + advance_to_new_work_first_level_at(&run, &next_profile, 3_000).expect("next run"); + + assert_eq!(next_run.entry_profile_id, "next"); + assert_eq!(next_run.cleared_level_count, 0); + assert_eq!(next_run.current_level_index, 1); + let next_level = next_run.current_level.expect("next level"); + assert_eq!(next_level.profile_id, "next"); + assert_eq!(next_level.level_index, 1); + assert_eq!(next_level.grid_size, 3); + assert_eq!(next_level.time_limit_ms, 300_000); + } + #[test] fn swap_pieces_marks_cleared_when_back_to_origin() { let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); @@ -1577,9 +3067,19 @@ mod tests { assert_ne!(first_positions, second_positions); } + #[test] + fn puzzle_point_incentive_uses_half_points_and_floor_claimable() { + // 中文注释:累计单位是 half point,消耗 1 个光点只让作者获得 0.5 个待结算光点。 + assert_eq!(puzzle_point_incentive_total_after_spend(0, 1), 1); + assert_eq!(puzzle_point_incentive_claimable_points(1, 0), 0); + assert_eq!(puzzle_point_incentive_claimable_points(2, 0), 1); + assert_eq!(puzzle_point_incentive_claimable_points(5, 1), 1); + assert_eq!(puzzle_point_incentive_claimable_points(5, 2), 0); + } + #[test] fn initial_board_has_no_original_neighbor_pairs() { - for grid_size in [3, 4] { + for grid_size in PUZZLE_SUPPORTED_GRID_SIZES { for shuffle_seed in 0..128 { let board = build_initial_board_with_seed(grid_size, shuffle_seed).expect("board"); @@ -1827,6 +3327,81 @@ mod tests { assert!(board.all_tiles_resolved); } + #[test] + fn timer_marks_running_level_failed_after_limit() { + let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); + let mut run = + start_run_with_shuffle_seed("run-timeout".to_string(), &profile, 0, 11).expect("run"); + let level = run.current_level.as_mut().expect("level"); + level.started_at_ms = current_unix_ms().saturating_sub(level.time_limit_ms + 1_000); + + let timed_run = resolve_puzzle_run_timer(run); + let timed_level = timed_run.current_level.as_ref().expect("level"); + + assert_eq!(timed_level.status, PuzzleRuntimeLevelStatus::Failed); + assert_eq!(timed_level.remaining_ms, 0); + assert_eq!(timed_level.elapsed_ms, Some(timed_level.time_limit_ms)); + } + + #[test] + fn failed_level_can_extend_one_minute() { + let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); + let now_ms = current_unix_ms(); + let mut run = + start_run_with_shuffle_seed("run-extend".to_string(), &profile, 0, 14).expect("run"); + let level = run.current_level.as_mut().expect("level"); + level.started_at_ms = now_ms.saturating_sub(level.time_limit_ms + 1_000); + + let failed_run = resolve_puzzle_run_timer_at(run, now_ms); + let extended_run = extend_failed_puzzle_time_at(&failed_run, now_ms + 5_000) + .expect("extend should succeed"); + let extended_level = extended_run.current_level.as_ref().expect("level"); + + assert_eq!(extended_level.status, PuzzleRuntimeLevelStatus::Playing); + assert_eq!(extended_level.remaining_ms, PUZZLE_EXTEND_TIME_DURATION_MS); + assert_eq!(extended_level.elapsed_ms, None); + assert_eq!(extended_level.cleared_at_ms, None); + assert_eq!(extended_level.pause_started_at_ms, None); + assert_eq!(extended_level.freeze_until_ms, None); + } + + #[test] + fn pause_and_freeze_are_excluded_from_effective_timer() { + let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); + let mut run = + start_run_with_shuffle_seed("run-freeze".to_string(), &profile, 0, 12).expect("run"); + let now_ms = current_unix_ms(); + let level = run.current_level.as_mut().expect("level"); + level.started_at_ms = now_ms.saturating_sub(30_000); + level.paused_accumulated_ms = 8_000; + level.pause_started_at_ms = Some(now_ms.saturating_sub(5_000)); + level.freeze_accumulated_ms = 4_000; + level.freeze_started_at_ms = Some(now_ms.saturating_sub(3_000)); + level.freeze_until_ms = Some(now_ms.saturating_add(7_000)); + + let remaining_ms = resolve_puzzle_runtime_remaining_ms(level, now_ms); + + assert_eq!(remaining_ms, level.time_limit_ms.saturating_sub(10_000)); + } + + #[test] + fn reference_preview_can_keep_run_paused_until_overlay_closes() { + let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); + let run = + start_run_with_shuffle_seed("run-reference".to_string(), &profile, 0, 13).expect("run"); + + let paused_run = set_puzzle_run_paused(&run, true).expect("pause"); + let still_paused_run = set_puzzle_run_paused(&paused_run, true).expect("reference pause"); + + assert!( + still_paused_run + .current_level + .as_ref() + .and_then(|level| level.pause_started_at_ms) + .is_some() + ); + } + #[test] fn apply_publish_overrides_updates_draft_truth() { let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙")); @@ -1834,6 +3409,8 @@ mod tests { let updated = apply_publish_overrides_to_draft( &draft, + Some("雨夜猫塔作品".to_string()), + Some("作品描述。".to_string()), Some("雨夜猫塔".to_string()), Some("一张更聚焦猫咪塔楼的夜景拼图。".to_string()), Some(vec![ @@ -1841,10 +3418,12 @@ mod tests { "猫咪".to_string(), "遗迹".to_string(), ]), + None, ) .expect("publish overrides should succeed"); assert_eq!(updated.level_name, "雨夜猫塔"); + assert_eq!(updated.work_title, "雨夜猫塔作品"); assert_eq!(updated.summary, "一张更聚焦猫咪塔楼的夜景拼图。"); assert_eq!( updated.theme_tags, @@ -1860,9 +3439,16 @@ mod tests { fn apply_publish_overrides_rejects_invalid_tag_count() { let anchor_pack = infer_anchor_pack("蒸汽城市", Some("蒸汽城市")); let draft = compile_result_draft(&anchor_pack, &[]); - let error = - apply_publish_overrides_to_draft(&draft, None, None, Some(vec!["蒸汽".to_string()])) - .expect_err("invalid tag count should fail"); + let error = apply_publish_overrides_to_draft( + &draft, + None, + None, + None, + None, + Some(vec!["蒸汽".to_string()]), + None, + ) + .expect_err("invalid tag count should fail"); assert_eq!(error, PuzzleFieldError::InvalidTagCount); } diff --git a/server-rs/crates/module-puzzle/src/commands.rs b/server-rs/crates/module-puzzle/src/commands.rs index 479b0a2a..aefb3b15 100644 --- a/server-rs/crates/module-puzzle/src/commands.rs +++ b/server-rs/crates/module-puzzle/src/commands.rs @@ -20,6 +20,15 @@ pub struct PuzzleAgentSessionCreateInput { pub created_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleFormDraftSaveInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub saved_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAgentSessionGetInput { @@ -64,6 +73,8 @@ pub struct PuzzleDraftCompileInput { pub struct PuzzleGeneratedImagesSaveInput { pub session_id: String, pub owner_user_id: String, + pub level_id: Option, + pub levels_json: Option, pub candidates_json: String, pub saved_at_micros: i64, } @@ -73,6 +84,7 @@ pub struct PuzzleGeneratedImagesSaveInput { pub struct PuzzleSelectCoverImageInput { pub session_id: String, pub owner_user_id: String, + pub level_id: Option, pub candidate_id: String, pub selected_at_micros: i64, } @@ -85,9 +97,12 @@ pub struct PuzzlePublishInput { pub work_id: String, pub profile_id: String, pub author_display_name: String, + pub work_title: Option, + pub work_description: Option, pub level_name: Option, pub summary: Option, pub theme_tags: Option>, + pub levels_json: Option, pub published_at_micros: i64, } @@ -115,20 +130,53 @@ pub struct PuzzleWorkDeleteInput { pub struct PuzzleWorkUpsertInput { pub profile_id: String, pub owner_user_id: String, + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, pub cover_image_src: Option, pub cover_asset_id: Option, + pub levels_json: Option, pub updated_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleWorkRemixInput { + pub source_profile_id: String, + pub target_owner_user_id: String, + pub target_session_id: String, + pub target_profile_id: String, + pub target_work_id: String, + pub author_display_name: String, + pub welcome_message_id: String, + pub remixed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleWorkLikeRecordInput { + pub profile_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleWorkPointIncentiveClaimInput { + pub profile_id: String, + pub owner_user_id: String, + pub claimed_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleRunStartInput { pub run_id: String, pub owner_user_id: String, pub profile_id: String, + pub level_id: Option, pub started_at_micros: i64, } @@ -168,6 +216,26 @@ pub struct PuzzleRunNextLevelInput { pub advanced_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleRunPauseInput { + pub run_id: String, + pub owner_user_id: String, + pub paused: bool, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleRunPropInput { + pub run_id: String, + pub owner_user_id: String, + pub prop_kind: String, + pub used_at_micros: i64, + #[serde(default)] + pub spent_points: u64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleLeaderboardSubmitInput { diff --git a/server-rs/crates/module-puzzle/src/domain.rs b/server-rs/crates/module-puzzle/src/domain.rs index 712da3dc..9ca7ac26 100644 --- a/server-rs/crates/module-puzzle/src/domain.rs +++ b/server-rs/crates/module-puzzle/src/domain.rs @@ -13,8 +13,21 @@ pub const PUZZLE_PROFILE_ID_PREFIX: &str = "puzzle-profile-"; pub const PUZZLE_RUN_ID_PREFIX: &str = "puzzle-run-"; pub const PUZZLE_MIN_TAG_COUNT: usize = 3; pub const PUZZLE_MAX_TAG_COUNT: usize = 6; +pub const PUZZLE_FREEZE_TIME_DURATION_MS: u64 = 10_000; +pub const PUZZLE_EXTEND_TIME_DURATION_MS: u64 = 60_000; +pub const PUZZLE_NEXT_LEVEL_MODE_SAME_WORK: &str = "sameWork"; +pub const PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS: &str = "similarWorks"; +pub const PUZZLE_NEXT_LEVEL_MODE_NONE: &str = "none"; +pub const PUZZLE_SUPPORTED_GRID_SIZES: [u32; 5] = [3, 4, 5, 6, 7]; pub(crate) const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64; +// 中文注释:拼图难度只从关卡序号解析,避免切割规格和倒计时在不同入口各写一套。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PuzzleLevelConfig { + pub grid_size: u32, + pub time_limit_ms: u64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum PuzzleAgentStage { @@ -63,68 +76,7 @@ pub enum PuzzlePublicationStatus { pub enum PuzzleRuntimeLevelStatus { Playing, Cleared, -} - -impl PuzzleAgentStage { - pub fn as_str(self) -> &'static str { - match self { - Self::CollectingAnchors => "collecting_anchors", - Self::DraftReady => "draft_ready", - Self::ImageRefining => "image_refining", - Self::ReadyToPublish => "ready_to_publish", - Self::Published => "published", - } - } -} - -impl PuzzleAnchorStatus { - pub fn as_str(self) -> &'static str { - match self { - Self::Missing => "missing", - Self::Inferred => "inferred", - Self::Confirmed => "confirmed", - Self::Locked => "locked", - } - } -} - -impl PuzzleAgentMessageRole { - pub fn as_str(self) -> &'static str { - match self { - Self::User => "user", - Self::Assistant => "assistant", - Self::System => "system", - } - } -} - -impl PuzzleAgentMessageKind { - pub fn as_str(self) -> &'static str { - match self { - Self::Chat => "chat", - Self::Summary => "summary", - Self::ActionResult => "action_result", - Self::Warning => "warning", - } - } -} - -impl PuzzlePublicationStatus { - pub fn as_str(self) -> &'static str { - match self { - Self::Draft => "draft", - Self::Published => "published", - } - } -} - -impl PuzzleRuntimeLevelStatus { - pub fn as_str(self) -> &'static str { - match self { - Self::Playing => "playing", - Self::Cleared => "cleared", - } - } + Failed, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -171,9 +123,26 @@ pub struct PuzzleGeneratedImageCandidate { pub selected: bool, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleDraftLevel { + pub level_id: String, + pub level_name: String, + pub picture_description: String, + pub candidates: Vec, + pub selected_candidate_id: Option, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub generation_status: String, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleResultDraft { + #[serde(default)] + pub work_title: String, + #[serde(default)] + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, @@ -185,6 +154,18 @@ pub struct PuzzleResultDraft { pub cover_image_src: Option, pub cover_asset_id: Option, pub generation_status: String, + #[serde(default)] + pub levels: Vec, + #[serde(default)] + pub form_draft: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleFormDraft { + pub work_title: Option, + pub work_description: Option, + pub picture_description: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -260,15 +241,32 @@ pub struct PuzzleWorkProfile { pub owner_user_id: String, pub source_session_id: Option, pub author_display_name: String, + #[serde(default)] + pub work_title: String, + #[serde(default)] + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, pub cover_image_src: Option, pub cover_asset_id: Option, + #[serde(default)] + pub levels: Vec, pub publication_status: PuzzlePublicationStatus, pub updated_at_micros: i64, pub published_at_micros: Option, + #[serde(default)] pub play_count: u32, + #[serde(default)] + pub remix_count: u32, + #[serde(default)] + pub like_count: u32, + #[serde(default)] + pub recent_play_count_7d: u32, + #[serde(default)] + pub point_incentive_total_half_points: u64, + #[serde(default)] + pub point_incentive_claimed_points: u64, pub publish_ready: bool, pub anchor_pack: PuzzleAnchorPack, } @@ -315,7 +313,9 @@ pub struct PuzzleBoardSnapshot { pub cols: u32, pub pieces: Vec, pub merged_groups: Vec, + #[serde(default)] pub selected_piece_id: Option, + #[serde(default)] pub all_tiles_resolved: bool, } @@ -324,6 +324,8 @@ pub struct PuzzleBoardSnapshot { pub struct PuzzleRuntimeLevelSnapshot { pub run_id: String, pub level_index: u32, + #[serde(default)] + pub level_id: Option, pub grid_size: u32, pub profile_id: String, pub level_name: String, @@ -332,14 +334,32 @@ pub struct PuzzleRuntimeLevelSnapshot { pub cover_image_src: Option, pub board: PuzzleBoardSnapshot, pub status: PuzzleRuntimeLevelStatus, + #[serde(default)] pub started_at_ms: u64, + #[serde(default)] pub cleared_at_ms: Option, + #[serde(default)] pub elapsed_ms: Option, + #[serde(default)] + pub time_limit_ms: u64, + #[serde(default)] + pub remaining_ms: u64, + #[serde(default)] + pub paused_accumulated_ms: u64, + #[serde(default)] + pub pause_started_at_ms: Option, + #[serde(default)] + pub freeze_accumulated_ms: u64, + #[serde(default)] + pub freeze_started_at_ms: Option, + #[serde(default)] + pub freeze_until_ms: Option, + #[serde(default)] pub leaderboard_entries: Vec, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct PuzzleRunSnapshot { pub run_id: String, pub entry_profile_id: String, @@ -350,5 +370,92 @@ pub struct PuzzleRunSnapshot { pub previous_level_tags: Vec, pub current_level: Option, pub recommended_next_profile_id: Option, + #[serde(default = "default_puzzle_next_level_mode")] + pub next_level_mode: String, + #[serde(default)] + pub next_level_profile_id: Option, + #[serde(default)] + pub next_level_id: Option, + #[serde(default)] + pub recommended_next_works: Vec, + #[serde(default)] pub leaderboard_entries: Vec, } + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PuzzleRecommendedNextWork { + pub profile_id: String, + pub level_name: String, + pub author_display_name: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub similarity_score: f32, +} + +pub(crate) fn default_puzzle_next_level_mode() -> String { + PUZZLE_NEXT_LEVEL_MODE_NONE.to_string() +} + +impl PuzzleAgentStage { + pub fn as_str(self) -> &'static str { + match self { + Self::CollectingAnchors => "collecting_anchors", + Self::DraftReady => "draft_ready", + Self::ImageRefining => "image_refining", + Self::ReadyToPublish => "ready_to_publish", + Self::Published => "published", + } + } +} + +impl PuzzleAnchorStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Missing => "missing", + Self::Inferred => "inferred", + Self::Confirmed => "confirmed", + Self::Locked => "locked", + } + } +} + +impl PuzzleAgentMessageRole { + pub fn as_str(self) -> &'static str { + match self { + Self::User => "user", + Self::Assistant => "assistant", + Self::System => "system", + } + } +} + +impl PuzzleAgentMessageKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Chat => "chat", + Self::Summary => "summary", + Self::ActionResult => "action_result", + Self::Warning => "warning", + } + } +} + +impl PuzzlePublicationStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Draft => "draft", + Self::Published => "published", + } + } +} + +impl PuzzleRuntimeLevelStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Playing => "playing", + Self::Cleared => "cleared", + Self::Failed => "failed", + } + } +} diff --git a/server-rs/crates/module-runtime-story/src/story_engine.rs b/server-rs/crates/module-runtime-story/src/story_engine.rs index 7e0c0db0..32fbaa7e 100644 --- a/server-rs/crates/module-runtime-story/src/story_engine.rs +++ b/server-rs/crates/module-runtime-story/src/story_engine.rs @@ -37,7 +37,11 @@ pub fn project_story_engine_after_action( battle_outcome, ); apply_thread_signal_updates(game_state, &mut memory, &signals); - ensure_scene_chapter_state(game_state, &mut memory); + // 中文注释:NPC 战斗入口只是把当前 NPC 切入战斗结算, + // 不能顺手触发“首进场景章节任务”。否则玩家点战斗会误以为被系统自动接了任务。 + if should_update_scene_chapter_state(function_id) { + ensure_scene_chapter_state(game_state, &mut memory); + } let previous_chapter = read_object_field(game_state, "chapterState") .or_else(|| read_object_field(&memory, "currentChapter")) @@ -141,6 +145,10 @@ fn ensure_array_field(root: &mut Map, key: &str) { } } +fn should_update_scene_chapter_state(function_id: &str) -> bool { + !matches!(function_id, "npc_fight" | "npc_spar") +} + fn collect_story_signals( previous_state: &Value, next_state: &Value, @@ -1566,4 +1574,51 @@ mod tests { .all(|mutation| mutation["mutationType"] != json!("enemy_pressure")) ); } + + #[test] + fn story_engine_projector_does_not_create_chapter_quest_on_npc_battle_entry() { + let previous_state = json!({ + "worldType": "WUXIA", + "currentScene": "Story", + "storyHistory": [], + "quests": [], + "currentScenePreset": { + "id": "scene-bridge", + "name": "断桥口", + "description": "风从桥下吹上来。", + "npcs": [{ + "id": "npc-guide", + "name": "沈七", + "hostile": false + }] + }, + "currentEncounter": { + "kind": "npc", + "id": "npc-guide", + "npcName": "沈七" + }, + "storyEngineMemory": { + "activeThreadIds": ["thread-bridge"] + } + }); + let mut next_state = previous_state.clone(); + next_state["inBattle"] = Value::Bool(true); + next_state["currentEncounter"] = Value::Null; + + project_story_engine_after_action( + &previous_state, + &mut next_state, + "与沈七战斗", + "沈七已经进入战斗节奏。", + "npc_fight", + Some("ongoing"), + ); + + assert!( + next_state["quests"] + .as_array() + .is_some_and(|items| items.is_empty()) + ); + assert_eq!(next_state["chapterState"]["chapterQuestId"], Value::Null); + } } diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index cf1af40c..44fac50f 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -175,6 +175,17 @@ pub fn build_runtime_referral_invite_center_record( today_inviter_reward_count: snapshot.today_inviter_reward_count, today_inviter_reward_remaining: snapshot.today_inviter_reward_remaining, reward_points: snapshot.reward_points, + invited_users: snapshot + .invited_users + .into_iter() + .map(|user| RuntimeReferralInvitedUserRecord { + user_id: user.user_id, + display_name: user.display_name, + avatar_url: user.avatar_url, + bound_at: format_utc_micros(user.bound_at_micros), + bound_at_micros: user.bound_at_micros, + }) + .collect(), has_redeemed_code: snapshot.has_redeemed_code, bound_inviter_user_id: snapshot.bound_inviter_user_id, bound_at: snapshot.bound_at_micros.map(format_utc_micros), @@ -225,6 +236,20 @@ pub fn build_runtime_profile_redeem_code_record( } } +pub fn build_runtime_profile_invite_code_record( + snapshot: RuntimeProfileInviteCodeSnapshot, +) -> RuntimeProfileInviteCodeRecord { + RuntimeProfileInviteCodeRecord { + user_id: snapshot.user_id, + invite_code: snapshot.invite_code, + metadata_json: snapshot.metadata_json, + created_at: format_utc_micros(snapshot.created_at_micros), + created_at_micros: snapshot.created_at_micros, + updated_at: format_utc_micros(snapshot.updated_at_micros), + updated_at_micros: snapshot.updated_at_micros, + } +} + pub fn build_runtime_profile_played_world_record( snapshot: RuntimeProfilePlayedWorldSnapshot, ) -> RuntimeProfilePlayedWorldRecord { diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 38a5cdac..ec993b8e 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -200,6 +200,25 @@ pub fn build_runtime_profile_redeem_code_admin_upsert_input( }) } +pub fn build_runtime_profile_invite_code_admin_upsert_input( + admin_user_id: String, + invite_code: String, + metadata_json: String, + updated_at_micros: i64, +) -> Result { + let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; + let invite_code = + normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?; + let metadata_json = normalize_invite_code_metadata_json(metadata_json)?; + + Ok(RuntimeProfileInviteCodeAdminUpsertInput { + admin_user_id, + invite_code, + metadata_json, + updated_at_micros, + }) +} + pub fn build_runtime_profile_redeem_code_admin_disable_input( admin_user_id: String, code: String, @@ -470,3 +489,23 @@ pub fn normalize_invite_code(value: String) -> Option { pub fn normalize_redeem_code(value: String) -> Option { normalize_invite_code(value) } + +pub fn normalize_invite_code_metadata_json( + value: String, +) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()); + } + if trimmed.len() > PROFILE_INVITE_CODE_METADATA_MAX_BYTES { + return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata); + } + + let parsed = serde_json::from_str::(trimmed) + .map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?; + if !parsed.is_object() { + return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata); + } + + serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata) +} diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 2b0bbd8d..6446f894 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -14,8 +14,11 @@ pub const DEFAULT_PLATFORM_THEME: RuntimePlatformTheme = RuntimePlatformTheme::L pub const DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME: &str = "玩家"; pub const MAX_BROWSE_HISTORY_BATCH_SIZE: usize = 100; pub const PROFILE_WALLET_LEDGER_LIST_LIMIT: usize = 50; +pub const PROFILE_NEW_USER_INITIAL_WALLET_POINTS: u64 = 10; pub const PROFILE_REFERRAL_REWARD_POINTS: u64 = 30; pub const PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT: u32 = 10; +pub const PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON: &str = "{}"; +pub const PROFILE_INVITE_CODE_METADATA_MAX_BYTES: usize = 4096; pub const PROFILE_RUNTIME_DAY_MICROS: i64 = 86_400_000_000; pub const SAVE_SNAPSHOT_VERSION: u32 = 2; pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。"; @@ -335,24 +338,28 @@ pub struct RuntimeProfileDashboardGetInput { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileWalletLedgerSourceType { SnapshotSync, + NewUserRegistrationReward, InviteInviterReward, InviteInviteeReward, PointsRecharge, AssetOperationConsume, AssetOperationRefund, RedeemCodeReward, + PuzzleAuthorIncentiveClaim, } impl RuntimeProfileWalletLedgerSourceType { pub fn as_str(&self) -> &'static str { match self { Self::SnapshotSync => "snapshot_sync", + Self::NewUserRegistrationReward => "new_user_registration_reward", Self::InviteInviterReward => "invite_inviter_reward", Self::InviteInviteeReward => "invite_invitee_reward", Self::PointsRecharge => "points_recharge", Self::AssetOperationConsume => "asset_operation_consume", Self::AssetOperationRefund => "asset_operation_refund", Self::RedeemCodeReward => "redeem_code_reward", + Self::PuzzleAuthorIncentiveClaim => "puzzle_author_incentive_claim", } } } @@ -649,6 +656,42 @@ pub struct RuntimeProfileRedeemCodeAdminProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileInviteCodeAdminUpsertInput { + pub admin_user_id: String, + pub invite_code: String, + pub metadata_json: String, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileInviteCodeSnapshot { + pub user_id: String, + pub invite_code: String, + pub metadata_json: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileInviteCodeAdminProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeReferralInvitedUserSnapshot { + pub user_id: String, + pub display_name: String, + pub avatar_url: Option, + pub bound_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeReferralInviteCenterSnapshot { @@ -660,6 +703,7 @@ pub struct RuntimeReferralInviteCenterSnapshot { pub today_inviter_reward_count: u32, pub today_inviter_reward_remaining: u32, pub reward_points: u64, + pub invited_users: Vec, pub has_redeemed_code: bool, pub bound_inviter_user_id: Option, pub bound_at_micros: Option, @@ -925,6 +969,26 @@ pub struct RuntimeProfileRedeemCodeRecord { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeProfileInviteCodeRecord { + pub user_id: String, + pub invite_code: String, + pub metadata_json: String, + pub created_at: String, + pub created_at_micros: i64, + pub updated_at: String, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeReferralInvitedUserRecord { + pub user_id: String, + pub display_name: String, + pub avatar_url: Option, + pub bound_at: String, + pub bound_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq)] pub struct RuntimeReferralInviteCenterRecord { pub user_id: String, @@ -935,6 +999,7 @@ pub struct RuntimeReferralInviteCenterRecord { pub today_inviter_reward_count: u32, pub today_inviter_reward_remaining: u32, pub reward_points: u64, + pub invited_users: Vec, pub has_redeemed_code: bool, pub bound_inviter_user_id: Option, pub bound_at: Option, diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index 7640509d..166c8d3b 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -51,6 +51,7 @@ pub enum RuntimeProfileFieldError { RedeemCodeNotAllowedForUser, InvalidRedeemCodeReward, InvalidRedeemCodeMaxUses, + InvalidInviteCodeMetadata, MissingProductId, MissingWorldKey, MissingBottomTab, @@ -82,6 +83,9 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::RedeemCodeNotAllowedForUser => f.write_str("该兑换码不适用于当前账号"), Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"), Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"), + Self::InvalidInviteCodeMetadata => { + f.write_str("邀请码 metadata 必须是合法 JSON object") + } Self::MissingProductId => f.write_str("recharge.product_id 不能为空"), Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"), diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index f40ab84a..31025687 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -1,4 +1,4 @@ -mod application; +mod application; mod commands; mod domain; mod errors; diff --git a/server-rs/crates/platform-auth/Cargo.toml b/server-rs/crates/platform-auth/Cargo.toml index 38ac0380..1214d342 100644 --- a/server-rs/crates/platform-auth/Cargo.toml +++ b/server-rs/crates/platform-auth/Cargo.toml @@ -20,7 +20,6 @@ time = { version = "0.3", features = ["std"] } tracing = "0.1" url = "2" urlencoding = "2" -uuid = { version = "1", features = ["v4"] } [dev-dependencies] tokio = { version = "1", features = ["macros", "rt"] } diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index c52208c9..2b4ffbd2 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -18,6 +18,7 @@ pub const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000; pub const DEFAULT_MAX_RETRIES: u32 = 1; pub const DEFAULT_RETRY_BACKOFF_MS: u64 = 500; pub const CHAT_COMPLETIONS_PATH: &str = "/chat/completions"; +pub const RESPONSES_PATH: &str = "/responses"; const DEFAULT_LLM_RAW_LOG_DIR: &str = "logs/llm-raw"; static LLM_RAW_LOG_SEQUENCE: AtomicU64 = AtomicU64::new(1); @@ -66,6 +67,14 @@ pub struct LlmTextRequest { pub messages: Vec, pub max_tokens: Option, pub enable_web_search: bool, + pub protocol: LlmTextProtocol, +} + +// 文本协议必须由业务请求显式选择,避免全局默认模型把不同场景混到同一上游形态。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LlmTextProtocol { + ChatCompletions, + Responses, } // 上层在流式消费时拿到的是“累计文本 + 当前增量”,避免每层重新自己拼接。 @@ -131,9 +140,16 @@ pub struct LlmClient { } #[derive(Serialize)] -struct ChatCompletionsRequestBody<'a> { - model: &'a str, - messages: &'a [LlmMessage], +#[serde(untagged)] +enum LlmRequestBody { + ChatCompletions(ChatCompletionsRequestBody), + Responses(ResponsesRequestBody), +} + +#[derive(Serialize)] +struct ChatCompletionsRequestBody { + model: String, + messages: Vec, stream: bool, #[serde(skip_serializing_if = "Option::is_none")] max_tokens: Option, @@ -144,10 +160,42 @@ struct ChatCompletionsRequestBody<'a> { #[derive(Serialize)] struct ChatCompletionsWebSearchOptions {} +#[derive(Serialize)] +struct ResponsesRequestBody { + model: String, + stream: bool, + input: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, +} + +#[derive(Serialize)] +struct ResponsesInputMessage { + role: &'static str, + content: Vec, +} + +#[derive(Serialize)] +struct ResponsesInputContentPart { + #[serde(rename = "type")] + part_type: &'static str, + text: String, +} + +#[derive(Serialize)] +struct ResponsesWebSearchTool { + #[serde(rename = "type")] + tool_type: &'static str, + max_keyword: u8, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct LlmRawFailureInputLog<'a> { provider: &'static str, + protocol: &'static str, model: &'a str, stream: bool, attempt: u32, @@ -195,10 +243,48 @@ struct ChatCompletionsContentPart { text: Option, } -#[derive(Default)] +#[derive(Deserialize)] +struct ResponsesResponseEnvelope { + id: Option, + model: Option, + #[serde(default)] + output_text: Option, + #[serde(default)] + output: Vec, + #[serde(default)] + status: Option, + usage: Option, +} + +#[derive(Deserialize)] +struct ResponsesOutputItem { + #[serde(default)] + content: Vec, +} + +#[derive(Deserialize)] +struct ResponsesOutputContentPart { + #[serde(rename = "type")] + #[allow(dead_code)] + part_type: Option, + #[serde(default)] + text: Option, +} + +#[derive(Deserialize)] +struct ResponsesUsage { + #[serde(default)] + input_tokens: u64, + #[serde(default)] + output_tokens: u64, + #[serde(default)] + total_tokens: u64, +} + struct OpenAiCompatibleSseParser { buffer: String, raw_text: String, + protocol: LlmTextProtocol, } #[derive(Debug)] @@ -296,6 +382,14 @@ impl LlmConfig { CHAT_COMPLETIONS_PATH.trim_start_matches('/') ) } + + pub fn responses_url(&self) -> String { + format!( + "{}/{}", + self.base_url.trim_end_matches('/'), + RESPONSES_PATH.trim_start_matches('/') + ) + } } impl LlmMessage { @@ -326,6 +420,7 @@ impl LlmTextRequest { messages, max_tokens: None, enable_web_search: false, + protocol: LlmTextProtocol::ChatCompletions, } } @@ -351,6 +446,11 @@ impl LlmTextRequest { self } + pub fn with_responses_api(mut self) -> Self { + self.protocol = LlmTextProtocol::Responses; + self + } + fn validate(&self) -> Result<(), LlmError> { if self.messages.is_empty() { return Err(LlmError::InvalidRequest( @@ -386,6 +486,15 @@ impl LlmTextRequest { } } +impl LlmTextProtocol { + fn as_str(self) -> &'static str { + match self { + Self::ChatCompletions => "chat_completions", + Self::Responses => "responses", + } + } +} + impl fmt::Display for LlmError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -460,18 +569,23 @@ impl LlmClient { llm_error })?; - parse_chat_completions_response(self.config.provider(), &resolved_model, raw_text.as_str()) - .map_err(|error| { - log_llm_raw_failure( - &self.config, - &request, - false, - 1, - "parse_response_failed", - raw_text.as_str(), - ); - error - }) + parse_text_response( + request.protocol, + self.config.provider(), + &resolved_model, + raw_text.as_str(), + ) + .map_err(|error| { + log_llm_raw_failure( + &self.config, + &request, + false, + 1, + "parse_response_failed", + raw_text.as_str(), + ); + error + }) } pub async fn request_single_message_text( @@ -500,7 +614,7 @@ impl LlmClient { .and_then(|value| value.to_str().ok()) .map(str::to_string); - let mut parser = OpenAiCompatibleSseParser::default(); + let mut parser = OpenAiCompatibleSseParser::new(request.protocol); let mut accumulated_text = String::new(); let mut finish_reason = None; let mut undecoded_chunk_bytes = Vec::new(); @@ -688,29 +802,27 @@ impl LlmClient { request: &LlmTextRequest, stream: bool, ) -> Result { - let request_body = ChatCompletionsRequestBody { - model: request.resolved_model(self.config.model()), - messages: request.messages.as_slice(), - stream, - max_tokens: request.max_tokens, - web_search_options: request - .enable_web_search - .then_some(ChatCompletionsWebSearchOptions {}), + let request_body = build_request_body(request, self.config.model(), stream); + let model = request.resolved_model(self.config.model()); + let url = match request.protocol { + LlmTextProtocol::ChatCompletions => self.config.chat_completions_url(), + LlmTextProtocol::Responses => self.config.responses_url(), }; let max_attempts = self.config.max_retries().saturating_add(1); for attempt in 1..=max_attempts { debug!( - "platform-llm request started: provider={}, stream={}, attempt={}, model={}", + "platform-llm request started: provider={}, protocol={}, stream={}, attempt={}, model={}", self.config.provider().as_str(), + request.protocol.as_str(), stream, attempt, - request_body.model + model ); let send_result = self .http_client - .post(self.config.chat_completions_url()) + .post(url.as_str()) .bearer_auth(self.config.api_key()) .json(&request_body) .timeout(Duration::from_millis(self.config.request_timeout_ms())) @@ -720,8 +832,9 @@ impl LlmClient { match send_result { Ok(response) if response.status().is_success() => { debug!( - "platform-llm request succeeded: provider={}, stream={}, attempt={}, status={}", + "platform-llm request succeeded: provider={}, protocol={}, stream={}, attempt={}, status={}", self.config.provider().as_str(), + request.protocol.as_str(), stream, attempt, response.status().as_u16() @@ -735,8 +848,9 @@ impl LlmClient { if should_retry_status(status) && attempt < max_attempts { warn!( - "platform-llm request retrying after upstream status: provider={}, attempt={}, status={}, message={}", + "platform-llm request retrying after upstream status: provider={}, protocol={}, attempt={}, status={}, message={}", self.config.provider().as_str(), + request.protocol.as_str(), attempt, status.as_u16(), message @@ -761,8 +875,9 @@ impl LlmClient { Err(error) if error.is_timeout() => { if attempt < max_attempts { warn!( - "platform-llm request retrying after timeout: provider={}, attempt={}", + "platform-llm request retrying after timeout: provider={}, protocol={}, attempt={}", self.config.provider().as_str(), + request.protocol.as_str(), attempt ); self.sleep_before_retry(attempt).await; @@ -784,8 +899,9 @@ impl LlmClient { let message = error.to_string(); if attempt < max_attempts { warn!( - "platform-llm request retrying after connectivity failure: provider={}, attempt={}, error={}", + "platform-llm request retrying after connectivity failure: provider={}, protocol={}, attempt={}, error={}", self.config.provider().as_str(), + request.protocol.as_str(), attempt, message ); @@ -840,6 +956,14 @@ impl LlmClient { } impl OpenAiCompatibleSseParser { + fn new(protocol: LlmTextProtocol) -> Self { + Self { + buffer: String::new(), + raw_text: String::new(), + protocol, + } + } + fn push_chunk(&mut self, chunk: &str) -> Result, LlmError> { self.raw_text.push_str(chunk); self.buffer.push_str(chunk); @@ -867,7 +991,7 @@ impl OpenAiCompatibleSseParser { let block = self.buffer[..boundary].to_string(); self.buffer = self.buffer[(boundary + 2)..].to_string(); - if let Some(event) = parse_sse_event_block(block.as_str())? { + if let Some(event) = parse_sse_event_block(self.protocol, block.as_str())? { events.push(event); } } @@ -885,6 +1009,55 @@ fn normalize_non_empty(value: String, error_message: &str) -> Result LlmRequestBody { + match request.protocol { + LlmTextProtocol::ChatCompletions => { + LlmRequestBody::ChatCompletions(ChatCompletionsRequestBody { + model: request.resolved_model(fallback_model).to_string(), + messages: request.messages.clone(), + stream, + max_tokens: request.max_tokens, + web_search_options: request + .enable_web_search + .then_some(ChatCompletionsWebSearchOptions {}), + }) + } + LlmTextProtocol::Responses => LlmRequestBody::Responses(ResponsesRequestBody { + model: request.resolved_model(fallback_model).to_string(), + stream, + input: map_responses_input_messages(request.messages.as_slice()), + max_output_tokens: request.max_tokens, + tools: request.enable_web_search.then(|| { + vec![ResponsesWebSearchTool { + tool_type: "web_search", + max_keyword: 3, + }] + }), + }), + } +} + +fn map_responses_input_messages(messages: &[LlmMessage]) -> Vec { + messages + .iter() + .map(|message| ResponsesInputMessage { + role: match message.role { + LlmMessageRole::System => "system", + LlmMessageRole::User => "user", + LlmMessageRole::Assistant => "assistant", + }, + content: vec![ResponsesInputContentPart { + part_type: "input_text", + text: message.content.clone(), + }], + }) + .collect() +} + fn log_llm_raw_failure( config: &LlmConfig, request: &LlmTextRequest, @@ -920,6 +1093,7 @@ fn write_llm_raw_failure( let model = request.resolved_model(config.model()); let input_log = LlmRawFailureInputLog { provider: config.provider().as_str(), + protocol: request.protocol.as_str(), model, stream, attempt, @@ -966,6 +1140,20 @@ fn sanitize_log_file_segment(value: &str) -> String { } } +fn parse_text_response( + protocol: LlmTextProtocol, + provider: LlmProvider, + fallback_model: &str, + raw_text: &str, +) -> Result { + match protocol { + LlmTextProtocol::ChatCompletions => { + parse_chat_completions_response(provider, fallback_model, raw_text) + } + LlmTextProtocol::Responses => parse_responses_response(provider, fallback_model, raw_text), + } +} + fn parse_chat_completions_response( provider: LlmProvider, fallback_model: &str, @@ -997,6 +1185,56 @@ fn parse_chat_completions_response( }) } +fn parse_responses_response( + provider: LlmProvider, + fallback_model: &str, + raw_text: &str, +) -> Result { + let parsed: ResponsesResponseEnvelope = serde_json::from_str(raw_text).map_err(|error| { + LlmError::Deserialize(format!("解析 LLM Responses JSON 响应失败:{error}")) + })?; + let content = extract_responses_text(&parsed) + .ok_or(LlmError::EmptyResponse)? + .trim() + .to_string(); + + if content.is_empty() { + return Err(LlmError::EmptyResponse); + } + + Ok(LlmTextResponse { + provider, + model: parsed.model.unwrap_or_else(|| fallback_model.to_string()), + content, + finish_reason: parsed.status, + response_id: parsed.id, + usage: parsed.usage.map(|usage| LlmTokenUsage { + prompt_tokens: usage.input_tokens, + completion_tokens: usage.output_tokens, + total_tokens: usage.total_tokens, + }), + }) +} + +fn extract_responses_text(parsed: &ResponsesResponseEnvelope) -> Option { + parsed + .output_text + .as_deref() + .map(str::to_string) + .filter(|text| !text.is_empty()) + .or_else(|| { + let text = parsed + .output + .iter() + .flat_map(|item| item.content.iter()) + .filter_map(|part| part.text.as_deref()) + .collect::>() + .join(""); + + if text.is_empty() { None } else { Some(text) } + }) +} + fn extract_message_text(choice: &ChatCompletionsChoice) -> Option { choice .message @@ -1046,7 +1284,10 @@ fn decode_utf8_stream_chunk(bytes: &[u8]) -> Result<(String, Vec), LlmError> } } -fn parse_sse_event_block(block: &str) -> Result, LlmError> { +fn parse_sse_event_block( + protocol: LlmTextProtocol, + block: &str, +) -> Result, LlmError> { let data_lines = block .lines() .filter_map(|line| line.trim().strip_prefix("data:")) @@ -1062,6 +1303,10 @@ fn parse_sse_event_block(block: &str) -> Result, LlmEr return Ok(None); } + if protocol == LlmTextProtocol::Responses { + return parse_responses_sse_event(data.as_str()); + } + let parsed: ChatCompletionsResponseEnvelope = serde_json::from_str(data.as_str()) .map_err(|error| LlmError::Deserialize(format!("解析 LLM SSE 事件失败:{error}")))?; let first_choice = parsed @@ -1075,6 +1320,44 @@ fn parse_sse_event_block(block: &str) -> Result, LlmEr })) } +fn parse_responses_sse_event(data: &str) -> Result, LlmError> { + let parsed: serde_json::Value = serde_json::from_str(data).map_err(|error| { + LlmError::Deserialize(format!("解析 LLM Responses SSE 事件失败:{error}")) + })?; + let event_type = parsed + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + + match event_type { + "response.output_text.delta" => Ok(Some(ParsedStreamEvent { + delta_text: parsed + .get("delta") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + finish_reason: None, + })), + "response.completed" => Ok(Some(ParsedStreamEvent { + delta_text: None, + finish_reason: Some("completed".to_string()), + })), + "response.failed" | "error" => { + let message = parsed + .get("error") + .and_then(|error| error.get("message")) + .and_then(serde_json::Value::as_str) + .or_else(|| parsed.get("message").and_then(serde_json::Value::as_str)) + .unwrap_or("LLM Responses SSE 返回失败事件") + .to_string(); + Err(LlmError::Upstream { + status_code: 502, + message, + }) + } + _ => Ok(None), + } +} + fn should_retry_status(status: StatusCode) -> bool { status == StatusCode::REQUEST_TIMEOUT || status == StatusCode::TOO_MANY_REQUESTS @@ -1198,11 +1481,12 @@ mod tests { config.chat_completions_url(), "https://example.com/base/chat/completions" ); + assert_eq!(config.responses_url(), "https://example.com/base/responses"); } #[test] fn sse_parser_handles_split_chunks_and_done_marker() { - let mut parser = OpenAiCompatibleSseParser::default(); + let mut parser = OpenAiCompatibleSseParser::new(LlmTextProtocol::ChatCompletions); let events_a = parser .push_chunk("data: {\"choices\":[{\"delta\":{\"content\":\"你\"}}]}\r\n\r\n") .expect("first chunk should parse"); @@ -1217,6 +1501,24 @@ mod tests { assert_eq!(events_b[0].finish_reason.as_deref(), Some("stop")); } + #[test] + fn responses_sse_parser_only_emits_output_text_delta() { + let mut parser = OpenAiCompatibleSseParser::new(LlmTextProtocol::Responses); + let events = parser + .push_chunk(concat!( + "data: {\"type\":\"response.created\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"你\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"好\"}\n\n", + "data: {\"type\":\"response.completed\"}\n\n", + )) + .expect("responses stream should parse"); + + assert_eq!(events.len(), 3); + assert_eq!(events[0].delta_text.as_deref(), Some("你")); + assert_eq!(events[1].delta_text.as_deref(), Some("好")); + assert_eq!(events[2].finish_reason.as_deref(), Some("completed")); + } + #[test] fn decode_utf8_stream_chunk_preserves_incomplete_multibyte_suffix() { let full_bytes = "你好".as_bytes(); @@ -1331,6 +1633,72 @@ mod tests { assert_eq!(request_json["web_search_options"], serde_json::json!({})); } + #[tokio::test] + async fn request_text_sends_responses_body_with_web_search_tool() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have addr"); + let server_handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("request should connect"); + let request_text = read_request(&mut stream); + write_response( + &mut stream, + MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"resp_responses","model":"deepseek-v3-2-251201","output_text":"Responses 成功","status":"completed","usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13}}"#.to_string(), + extra_headers: Vec::new(), + }, + ); + request_text + }); + + let client = build_test_client(format!("http://{address}"), 0); + let response = client + .request_text( + LlmTextRequest::single_turn("系统", "用户") + .with_model("deepseek-v3-2-251201") + .with_responses_api() + .with_web_search(true) + .with_max_tokens(128), + ) + .await + .expect("responses request_text should succeed"); + + let request_text = server_handle.join().expect("server thread should join"); + let request_line = request_text.lines().next().unwrap_or_default(); + let request_body = request_text + .split("\r\n\r\n") + .nth(1) + .expect("request body should exist"); + let request_json: serde_json::Value = + serde_json::from_str(request_body).expect("request body should be json"); + + assert!(request_line.contains("POST /responses HTTP/1.1")); + assert_eq!(response.content, "Responses 成功"); + assert_eq!(response.model, "deepseek-v3-2-251201"); + assert_eq!( + response.usage, + Some(LlmTokenUsage { + prompt_tokens: 9, + completion_tokens: 4, + total_tokens: 13, + }) + ); + assert_eq!( + request_json["model"], + serde_json::json!("deepseek-v3-2-251201") + ); + assert_eq!(request_json["stream"], serde_json::json!(false)); + assert_eq!( + request_json["tools"], + serde_json::json!([{ "type": "web_search", "max_keyword": 3 }]) + ); + assert_eq!( + request_json["input"][0]["content"][0], + serde_json::json!({ "type": "input_text", "text": "系统" }) + ); + } + #[tokio::test] async fn stream_text_accumulates_sse_response() { let server_url = spawn_mock_server(vec![MockResponse { @@ -1361,6 +1729,41 @@ mod tests { assert_eq!(response.response_id.as_deref(), Some("req_stream_01")); } + #[tokio::test] + async fn stream_text_accumulates_responses_sse_response() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"你\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"好\"}\n\n", + "data: {\"type\":\"response.completed\"}\n\n" + ) + .to_string(), + extra_headers: vec![("x-request-id", "req_responses_stream_01")], + }]); + + let client = build_test_client(server_url, 0); + let mut updates = Vec::new(); + let response = client + .stream_text( + LlmTextRequest::single_turn("系统", "用户").with_responses_api(), + |delta| { + updates.push(delta.accumulated_text.clone()); + }, + ) + .await + .expect("responses stream_text should succeed"); + + assert_eq!(updates, vec!["你".to_string(), "你好".to_string()]); + assert_eq!(response.content, "你好"); + assert_eq!(response.finish_reason.as_deref(), Some("completed")); + assert_eq!( + response.response_id.as_deref(), + Some("req_responses_stream_01") + ); + } + #[tokio::test] async fn request_text_writes_raw_failure_logs_after_parse_error() { let log_dir = std::env::temp_dir().join(format!( diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index 9821054b..3bd58ccf 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -19,10 +19,12 @@ pub struct AuthUserPayload { pub public_user_code: String, pub username: String, pub display_name: String, + pub avatar_url: Option, pub phone_number_masked: Option, pub login_method: String, pub binding_status: String, pub wechat_bound: bool, + pub created_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -31,6 +33,7 @@ pub struct PublicUserSummaryPayload { pub id: String, pub public_user_code: String, pub display_name: String, + pub avatar_url: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -66,6 +69,19 @@ pub struct PasswordChangeResponse { pub user: AuthUserPayload, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileUpdateRequest { + pub display_name: Option, + pub avatar_data_url: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileUpdateResponse { + pub user: AuthUserPayload, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PasswordResetRequest { @@ -149,6 +165,8 @@ pub struct PhoneSendCodeResponse { pub struct PhoneLoginRequest { pub phone: String, pub code: String, + #[serde(default)] + pub invite_code: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -156,6 +174,19 @@ pub struct PhoneLoginRequest { pub struct PhoneLoginResponse { pub token: String, pub user: AuthUserPayload, + pub created: bool, + pub referral: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PhoneLoginReferralResponse { + pub ok: bool, + pub message: Option, + pub invitee_reward_granted: bool, + pub inviter_reward_granted: bool, + pub invitee_balance_after: Option, + pub inviter_balance_after: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -252,6 +283,23 @@ mod tests { ); } + #[test] + fn profile_update_request_uses_camel_case_fields() { + let payload = serde_json::to_value(ProfileUpdateRequest { + display_name: Some("旅人甲".to_string()), + avatar_data_url: Some("data:image/png;base64,AAAA".to_string()), + }) + .expect("payload should serialize"); + + assert_eq!( + payload, + json!({ + "displayName": "旅人甲", + "avatarDataUrl": "data:image/png;base64,AAAA" + }) + ); + } + #[test] fn wechat_callback_query_keeps_provider_compatible_field_names() { let payload = serde_json::to_value(WechatCallbackQuery { diff --git a/server-rs/crates/shared-contracts/src/big_fish_works.rs b/server-rs/crates/shared-contracts/src/big_fish_works.rs index b44cd94a..255c87ed 100644 --- a/server-rs/crates/shared-contracts/src/big_fish_works.rs +++ b/server-rs/crates/shared-contracts/src/big_fish_works.rs @@ -6,6 +6,7 @@ pub struct BigFishWorkSummaryResponse { pub work_id: String, pub source_session_id: String, pub owner_user_id: String, + pub author_display_name: String, pub title: String, pub subtitle: String, pub summary: String, @@ -13,6 +14,8 @@ pub struct BigFishWorkSummaryResponse { pub cover_image_src: Option, pub status: String, pub updated_at: String, + #[serde(default)] + pub published_at: Option, pub publish_ready: bool, pub level_count: u32, pub level_main_image_ready_count: u32, @@ -20,6 +23,12 @@ pub struct BigFishWorkSummaryResponse { pub background_ready: bool, #[serde(default)] pub play_count: u32, + #[serde(default)] + pub remix_count: u32, + #[serde(default)] + pub like_count: u32, + #[serde(default)] + pub recent_play_count_7d: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index c54e622c..e71ba254 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -7,6 +7,9 @@ pub mod big_fish; pub mod big_fish_works; pub mod creation_agent_document_input; pub mod llm; +pub mod match3d_agent; +pub mod match3d_runtime; +pub mod match3d_works; pub mod puzzle_agent; pub mod puzzle_gallery; pub mod puzzle_runtime; diff --git a/server-rs/crates/shared-contracts/src/match3d_agent.rs b/server-rs/crates/shared-contracts/src/match3d_agent.rs new file mode 100644 index 00000000..8db4ea95 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/match3d_agent.rs @@ -0,0 +1,161 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreateMatch3DAgentSessionRequest { + #[serde(default)] + pub seed_text: Option, + #[serde(default)] + pub theme_text: Option, + #[serde(default)] + pub reference_image_src: Option, + #[serde(default)] + pub clear_count: Option, + #[serde(default)] + pub difficulty: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SendMatch3DAgentMessageRequest { + pub client_message_id: String, + pub text: String, + #[serde(default)] + pub quick_fill_requested: Option, + #[serde(default)] + pub reference_image_src: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ExecuteMatch3DAgentActionRequest { + pub action: String, + #[serde(default)] + pub game_name: Option, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub tags: Option>, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub clear_count: Option, + #[serde(default)] + pub difficulty: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DCreatorConfigResponse { + pub theme_text: String, + #[serde(default)] + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DResultDraftResponse { + pub profile_id: String, + pub game_name: String, + pub theme_text: String, + #[serde(default)] + pub summary_text: Option, + pub summary: String, + pub tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub total_item_count: u32, + pub publish_ready: bool, + pub blockers: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAnchorItemResponse { + pub key: String, + pub label: String, + pub value: String, + pub status: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAnchorPackResponse { + pub theme: Match3DAnchorItemResponse, + pub clear_count: Match3DAnchorItemResponse, + pub difficulty: Match3DAnchorItemResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentMessageResponse { + pub id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentSessionSnapshotResponse { + pub session_id: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub anchor_pack: Match3DAnchorPackResponse, + #[serde(default)] + pub config: Option, + #[serde(default)] + pub draft: Option, + pub messages: Vec, + #[serde(default)] + pub last_assistant_reply: Option, + #[serde(default)] + pub published_profile_id: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentSessionResponse { + pub session: Match3DAgentSessionSnapshotResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentActionResponse { + pub session: Match3DAgentSessionSnapshotResponse, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn create_match3d_session_request_uses_camel_case() { + let payload = serde_json::to_value(CreateMatch3DAgentSessionRequest { + seed_text: Some("水果消除".to_string()), + theme_text: Some("水果".to_string()), + reference_image_src: Some("data:image/png;base64,abc".to_string()), + clear_count: Some(4), + difficulty: Some(3), + }) + .expect("payload should serialize"); + + assert_eq!(payload["seedText"], json!("水果消除")); + assert_eq!(payload["themeText"], json!("水果")); + assert_eq!( + payload["referenceImageSrc"], + json!("data:image/png;base64,abc") + ); + assert_eq!(payload["clearCount"], json!(4)); + } +} diff --git a/server-rs/crates/shared-contracts/src/match3d_runtime.rs b/server-rs/crates/shared-contracts/src/match3d_runtime.rs new file mode 100644 index 00000000..46952cf2 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/match3d_runtime.rs @@ -0,0 +1,125 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StartMatch3DRunRequest { + pub profile_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ClickMatch3DItemRequest { + #[serde(default)] + pub run_id: Option, + pub item_instance_id: String, + pub client_snapshot_version: u64, + pub client_event_id: String, + pub clicked_at_ms: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StopMatch3DRunRequest { + pub client_action_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DItemSnapshotResponse { + pub item_instance_id: String, + pub item_type_id: String, + pub visual_key: String, + pub x: f32, + pub y: f32, + pub radius: f32, + pub layer: u32, + pub state: String, + pub clickable: bool, + #[serde(default)] + pub tray_slot_index: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DTraySlotResponse { + pub slot_index: u32, + #[serde(default)] + pub item_instance_id: Option, + #[serde(default)] + pub item_type_id: Option, + #[serde(default)] + pub visual_key: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DRunSnapshotResponse { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: String, + /// 对外 HTTP 快照版本。领域层内部字段名为 board_version,facade 需要在这里完成映射。 + pub snapshot_version: u64, + pub started_at_ms: u64, + pub duration_limit_ms: u64, + #[serde(default)] + pub server_now_ms: Option, + pub remaining_ms: u64, + pub clear_count: u32, + pub total_item_count: u32, + pub cleared_item_count: u32, + pub items: Vec, + pub tray_slots: Vec, + #[serde(default)] + pub failure_reason: Option, + #[serde(default)] + pub last_confirmed_action_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DClickConfirmationResponse { + pub accepted: bool, + #[serde(default)] + pub reject_reason: Option, + #[serde(default)] + pub entered_slot_index: Option, + pub cleared_item_instance_ids: Vec, + pub run: Match3DRunSnapshotResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DRunResponse { + pub run: Match3DRunSnapshotResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DClickResponse { + pub confirmation: Match3DClickConfirmationResponse, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn click_match3d_item_request_uses_camel_case() { + let payload = serde_json::to_value(ClickMatch3DItemRequest { + run_id: Some("run-1".to_string()), + item_instance_id: "item-1".to_string(), + client_snapshot_version: 7, + client_event_id: "event-1".to_string(), + clicked_at_ms: 12_345, + }) + .expect("payload should serialize"); + + assert_eq!(payload["runId"], json!("run-1")); + assert_eq!(payload["itemInstanceId"], json!("item-1")); + assert_eq!(payload["clientSnapshotVersion"], json!(7)); + assert_eq!(payload["clientEventId"], json!("event-1")); + assert_eq!(payload["clickedAtMs"], json!(12_345)); + } +} diff --git a/server-rs/crates/shared-contracts/src/match3d_works.rs b/server-rs/crates/shared-contracts/src/match3d_works.rs new file mode 100644 index 00000000..3bf85e55 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/match3d_works.rs @@ -0,0 +1,92 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PutMatch3DWorkRequest { + pub game_name: String, + #[serde(default)] + pub theme_text: Option, + pub summary: String, + pub tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkSummaryResponse { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + #[serde(default)] + pub source_session_id: Option, + pub game_name: String, + pub theme_text: String, + pub summary: String, + pub tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publication_status: String, + pub play_count: u32, + pub updated_at: String, + #[serde(default)] + pub published_at: Option, + pub publish_ready: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkProfileResponse { + #[serde(flatten)] + pub summary: Match3DWorkSummaryResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorksResponse { + pub items: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkDetailResponse { + pub item: Match3DWorkProfileResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkMutationResponse { + pub item: Match3DWorkProfileResponse, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn match3d_work_request_uses_camel_case() { + let payload = serde_json::to_value(PutMatch3DWorkRequest { + game_name: "水果抓大鹅".to_string(), + theme_text: Some("水果".to_string()), + summary: "水果主题".to_string(), + tags: vec!["水果".to_string()], + cover_image_src: None, + reference_image_src: None, + clear_count: 4, + difficulty: 5, + }) + .expect("payload should serialize"); + + assert_eq!(payload["gameName"], json!("水果抓大鹅")); + assert_eq!(payload["clearCount"], json!(4)); + } +} diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index 4409984c..d46d2e29 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -5,6 +5,14 @@ use serde::{Deserialize, Serialize}; pub struct CreatePuzzleAgentSessionRequest { #[serde(default)] pub seed_text: Option, + #[serde(default)] + pub work_title: Option, + #[serde(default)] + pub work_description: Option, + #[serde(default)] + pub picture_description: Option, + #[serde(default)] + pub reference_image_src: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -29,11 +37,32 @@ pub struct ExecutePuzzleAgentActionRequest { #[serde(default)] pub candidate_id: Option, #[serde(default)] + pub level_id: Option, + #[serde(default)] + pub work_title: Option, + #[serde(default)] + pub work_description: Option, + #[serde(default)] + pub picture_description: Option, + #[serde(default)] pub level_name: Option, #[serde(default)] pub summary: Option, #[serde(default)] pub theme_tags: Option>, + #[serde(default)] + pub levels_json: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleFormDraftResponse { + #[serde(default)] + pub work_title: Option, + #[serde(default)] + pub work_description: Option, + #[serde(default)] + pub picture_description: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -84,6 +113,8 @@ pub struct PuzzleCreatorIntentResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct PuzzleResultDraftResponse { + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, @@ -99,6 +130,25 @@ pub struct PuzzleResultDraftResponse { #[serde(default)] pub cover_asset_id: Option, pub generation_status: String, + pub levels: Vec, + #[serde(default)] + pub form_draft: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleDraftLevelResponse { + pub level_id: String, + pub level_name: String, + pub picture_description: String, + pub candidates: Vec, + #[serde(default)] + pub selected_candidate_id: Option, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub cover_asset_id: Option, + pub generation_status: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -149,6 +199,7 @@ pub struct PuzzleResultPreviewEnvelopeResponse { #[serde(rename_all = "camelCase")] pub struct PuzzleAgentSessionSnapshotResponse { pub session_id: String, + pub seed_text: String, pub current_turn: u32, pub progress_percent: u32, pub stage: String, diff --git a/server-rs/crates/shared-contracts/src/puzzle_gallery.rs b/server-rs/crates/shared-contracts/src/puzzle_gallery.rs index 57e73b04..daed2603 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_gallery.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_gallery.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::puzzle_works::PuzzleWorkSummaryResponse; +use crate::puzzle_works::{PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse}; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -11,5 +11,5 @@ pub struct PuzzleGalleryResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct PuzzleGalleryDetailResponse { - pub item: PuzzleWorkSummaryResponse, + pub item: PuzzleWorkProfileResponse, } diff --git a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs index 91d63e50..93d25d6a 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "camelCase")] pub struct StartPuzzleRunRequest { pub profile_id: String, + #[serde(default)] + pub level_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -21,6 +23,18 @@ pub struct DragPuzzlePieceRequest { pub target_col: u32, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UsePuzzleRuntimePropRequest { + pub prop_kind: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UpdatePuzzleRuntimePauseRequest { + pub paused: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SubmitPuzzleLeaderboardRequest { @@ -84,6 +98,8 @@ pub struct PuzzleBoardSnapshotResponse { pub struct PuzzleRuntimeLevelSnapshotResponse { pub run_id: String, pub level_index: u32, + #[serde(default)] + pub level_id: Option, pub grid_size: u32, pub profile_id: String, pub level_name: String, @@ -100,9 +116,35 @@ pub struct PuzzleRuntimeLevelSnapshotResponse { #[serde(default)] pub elapsed_ms: Option, #[serde(default)] + pub time_limit_ms: u64, + #[serde(default)] + pub remaining_ms: u64, + #[serde(default)] + pub paused_accumulated_ms: u64, + #[serde(default)] + pub pause_started_at_ms: Option, + #[serde(default)] + pub freeze_accumulated_ms: u64, + #[serde(default)] + pub freeze_started_at_ms: Option, + #[serde(default)] + pub freeze_until_ms: Option, + #[serde(default)] pub leaderboard_entries: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleRecommendedNextWorkResponse { + pub profile_id: String, + pub level_name: String, + pub author_display_name: String, + pub theme_tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + pub similarity_score: f32, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct PuzzleRunSnapshotResponse { @@ -118,6 +160,14 @@ pub struct PuzzleRunSnapshotResponse { #[serde(default)] pub recommended_next_profile_id: Option, #[serde(default)] + pub next_level_mode: String, + #[serde(default)] + pub next_level_profile_id: Option, + #[serde(default)] + pub next_level_id: Option, + #[serde(default)] + pub recommended_next_works: Vec, + #[serde(default)] pub leaderboard_entries: Vec, } diff --git a/server-rs/crates/shared-contracts/src/puzzle_works.rs b/server-rs/crates/shared-contracts/src/puzzle_works.rs index 8c2e4bdb..cdc14bb3 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_works.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_works.rs @@ -1,10 +1,12 @@ use serde::{Deserialize, Serialize}; -use crate::puzzle_agent::PuzzleAnchorPackResponse; +use crate::puzzle_agent::{PuzzleAnchorPackResponse, PuzzleDraftLevelResponse}; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct PutPuzzleWorkRequest { + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, @@ -12,6 +14,8 @@ pub struct PutPuzzleWorkRequest { pub cover_image_src: Option, #[serde(default)] pub cover_asset_id: Option, + #[serde(default)] + pub levels: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -23,6 +27,8 @@ pub struct PuzzleWorkSummaryResponse { #[serde(default)] pub source_session_id: Option, pub author_display_name: String, + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, @@ -35,7 +41,65 @@ pub struct PuzzleWorkSummaryResponse { #[serde(default)] pub published_at: Option, pub play_count: u32, + #[serde(default)] + pub remix_count: u32, + #[serde(default)] + pub like_count: u32, + #[serde(default)] + pub recent_play_count_7d: u32, + #[serde(default)] + pub point_incentive_total_half_points: u64, + #[serde(default)] + pub point_incentive_claimed_points: u64, + #[serde(default)] + pub point_incentive_total_points: f64, + #[serde(default)] + pub point_incentive_claimable_points: u64, pub publish_ready: bool, + #[serde(default)] + pub levels: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn puzzle_work_summary_response_uses_point_incentive_fields() { + let payload = serde_json::to_value(PuzzleWorkSummaryResponse { + work_id: "work-1".to_string(), + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: None, + author_display_name: "作者".to_string(), + work_title: "作品".to_string(), + work_description: "描述".to_string(), + level_name: "第一关".to_string(), + summary: "画面".to_string(), + theme_tags: vec!["拼图".to_string(), "夜色".to_string(), "灯光".to_string()], + cover_image_src: None, + cover_asset_id: None, + publication_status: "published".to_string(), + updated_at: "2026-05-01T00:00:00Z".to_string(), + published_at: Some("2026-05-01T00:00:00Z".to_string()), + play_count: 1, + remix_count: 0, + like_count: 0, + recent_play_count_7d: 1, + point_incentive_total_half_points: 3, + point_incentive_claimed_points: 1, + point_incentive_total_points: 1.5, + point_incentive_claimable_points: 0, + publish_ready: true, + levels: Vec::new(), + }) + .expect("payload should serialize"); + + assert_eq!(payload["pointIncentiveTotalHalfPoints"], 3); + assert_eq!(payload["pointIncentiveClaimedPoints"], 1); + assert_eq!(payload["pointIncentiveTotalPoints"], 1.5); + assert_eq!(payload["pointIncentiveClaimablePoints"], 0); + } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 8710d78c..a7798c04 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -4,6 +4,8 @@ pub const RUNTIME_PLATFORM_THEME_LIGHT: &str = "light"; pub const RUNTIME_PLATFORM_THEME_DARK: &str = "dark"; pub const SAVE_SNAPSHOT_VERSION: u32 = 2; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC: &str = "snapshot_sync"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD: &str = + "new_user_registration_reward"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE: &str = "points_recharge"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD: &str = "invite_inviter_reward"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD: &str = "invite_invitee_reward"; @@ -11,6 +13,8 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME: &str = "asset_operation_consume"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND: &str = "asset_operation_refund"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM: &str = + "puzzle_author_incentive_claim"; pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial"; pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane"; pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina"; @@ -235,6 +239,15 @@ pub struct CreateProfileRechargeOrderResponse { pub center: ProfileRechargeCenterResponse, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileReferralInvitedUserResponse { + pub user_id: String, + pub display_name: String, + pub avatar_url: Option, + pub bound_at: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileReferralInviteCenterResponse { @@ -245,6 +258,7 @@ pub struct ProfileReferralInviteCenterResponse { pub today_inviter_reward_count: u32, pub today_inviter_reward_remaining: u32, pub reward_points: u64, + pub invited_users: Vec, pub has_redeemed_code: bool, pub bound_inviter_user_id: Option, pub bound_at: Option, @@ -296,6 +310,14 @@ pub struct AdminUpsertProfileRedeemCodeRequest { pub allowed_public_user_codes: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminUpsertProfileInviteCodeRequest { + pub invite_code: String, + #[serde(default)] + pub metadata: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AdminDisableProfileRedeemCodeRequest { @@ -317,6 +339,16 @@ pub struct ProfileRedeemCodeAdminResponse { pub updated_at: String, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileInviteCodeAdminResponse { + pub user_id: String, + pub invite_code: String, + pub metadata: serde_json::Value, + pub created_at: String, + pub updated_at: String, +} + fn default_true() -> bool { true } @@ -437,6 +469,14 @@ pub struct CustomWorldLibraryEntryResponse { pub theme_mode: String, pub playable_npc_count: u32, pub landmark_count: u32, + #[serde(default)] + pub play_count: u32, + #[serde(default)] + pub remix_count: u32, + #[serde(default)] + pub like_count: u32, + #[serde(default)] + pub recent_play_count_7d: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -457,6 +497,14 @@ pub struct CustomWorldGalleryCardResponse { pub theme_mode: String, pub playable_npc_count: u32, pub landmark_count: u32, + #[serde(default)] + pub play_count: u32, + #[serde(default)] + pub remix_count: u32, + #[serde(default)] + pub like_count: u32, + #[serde(default)] + pub recent_play_count_7d: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -850,13 +898,21 @@ mod tests { entries: vec![ ProfileWalletLedgerEntryResponse { id: "ledger-1".to_string(), + amount_delta: 10, + balance_after: 10, + source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD + .to_string(), + created_at: "2026-04-22T09:59:00Z".to_string(), + }, + ProfileWalletLedgerEntryResponse { + id: "ledger-2".to_string(), amount_delta: 12, balance_after: 80, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(), created_at: "2026-04-22T10:00:00Z".to_string(), }, ProfileWalletLedgerEntryResponse { - id: "ledger-2".to_string(), + id: "ledger-3".to_string(), amount_delta: 30, balance_after: 110, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD @@ -864,7 +920,7 @@ mod tests { created_at: "2026-04-22T10:01:00Z".to_string(), }, ProfileWalletLedgerEntryResponse { - id: "ledger-3".to_string(), + id: "ledger-4".to_string(), amount_delta: 30, balance_after: 140, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD @@ -872,14 +928,14 @@ mod tests { created_at: "2026-04-22T10:02:00Z".to_string(), }, ProfileWalletLedgerEntryResponse { - id: "ledger-4".to_string(), + id: "ledger-5".to_string(), amount_delta: 60, balance_after: 200, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE.to_string(), created_at: "2026-04-22T10:03:00Z".to_string(), }, ProfileWalletLedgerEntryResponse { - id: "ledger-5".to_string(), + id: "ledger-6".to_string(), amount_delta: -1, balance_after: 199, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME @@ -887,46 +943,62 @@ mod tests { created_at: "2026-04-22T10:04:00Z".to_string(), }, ProfileWalletLedgerEntryResponse { - id: "ledger-6".to_string(), + id: "ledger-7".to_string(), amount_delta: 1, balance_after: 200, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND .to_string(), created_at: "2026-04-22T10:05:00Z".to_string(), }, + ProfileWalletLedgerEntryResponse { + id: "ledger-8".to_string(), + amount_delta: 2, + balance_after: 202, + source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM + .to_string(), + created_at: "2026-04-22T10:06:00Z".to_string(), + }, ], }) .expect("payload should serialize"); - assert_eq!(payload["entries"][0]["amountDelta"], json!(12)); - assert_eq!(payload["entries"][0]["balanceAfter"], json!(80)); + assert_eq!(payload["entries"][0]["amountDelta"], json!(10)); + assert_eq!(payload["entries"][0]["balanceAfter"], json!(10)); assert_eq!( payload["entries"][0]["sourceType"], - json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC) + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD) ); assert_eq!( payload["entries"][1]["sourceType"], - json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD) + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC) ); assert_eq!( payload["entries"][2]["sourceType"], - json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD) + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD) ); assert_eq!( payload["entries"][3]["sourceType"], - json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE) + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD) ); assert_eq!( payload["entries"][4]["sourceType"], - json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME) + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE) ); assert_eq!( payload["entries"][5]["sourceType"], + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME) + ); + assert_eq!( + payload["entries"][6]["sourceType"], json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND) ); + assert_eq!( + payload["entries"][7]["sourceType"], + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM) + ); assert_eq!( payload["entries"][0]["createdAt"], - json!("2026-04-22T10:00:00Z") + json!("2026-04-22T09:59:00Z") ); } @@ -943,14 +1015,14 @@ mod tests { }, point_products: vec![ProfileRechargeProductResponse { product_id: "points_60".to_string(), - title: "60叙世币".to_string(), + title: "60光点".to_string(), price_cents: 600, kind: "points".to_string(), points_amount: 60, bonus_points: 60, duration_days: 0, badge_label: "首充双倍".to_string(), - description: "首充送60叙世币".to_string(), + description: "首充送60光点".to_string(), tier: "normal".to_string(), }], membership_products: vec![], @@ -966,11 +1038,11 @@ mod tests { json!("2026-05-25T10:00:00Z") ); assert_eq!(payload["pointProducts"][0]["productId"], json!("points_60")); - assert_eq!(payload["pointProducts"][0]["title"], json!("60叙世币")); + assert_eq!(payload["pointProducts"][0]["title"], json!("60光点")); assert_eq!(payload["pointProducts"][0]["priceCents"], json!(600)); assert_eq!( payload["pointProducts"][0]["description"], - json!("首充送60叙世币") + json!("首充送60光点") ); assert_eq!(payload["hasPointsRecharged"], json!(false)); } diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml index 4b173265..e55b0b00 100644 --- a/server-rs/crates/spacetime-client/Cargo.toml +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -6,11 +6,12 @@ license.workspace = true [dependencies] module-ai = { path = "../module-ai" } -module-big-fish = { path = "../module-big-fish" } -module-custom-world = { path = "../module-custom-world" } module-assets = { path = "../module-assets" } +module-big-fish = { path = "../module-big-fish" } module-combat = { path = "../module-combat" } +module-custom-world = { path = "../module-custom-world" } module-inventory = { path = "../module-inventory" } +module-match3d = { path = "../module-match3d" } module-npc = { path = "../module-npc" } module-puzzle = { path = "../module-puzzle" } module-runtime = { path = "../module-runtime" } diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index 316a27be..699cb5a6 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -2,9 +2,12 @@ use super::*; use crate::mapper::*; use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work; use crate::module_bindings::get_big_fish_run_procedure::get_big_fish_run; +use crate::module_bindings::record_big_fish_like_procedure::record_big_fish_like; use crate::module_bindings::record_big_fish_play_procedure::record_big_fish_play; +use crate::module_bindings::remix_big_fish_work_procedure::remix_big_fish_work; use crate::module_bindings::start_big_fish_run_procedure::start_big_fish_run; use crate::module_bindings::submit_big_fish_input_procedure::submit_big_fish_input; +use module_big_fish::PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID; impl SpacetimeClient { pub async fn create_big_fish_session( @@ -73,7 +76,8 @@ impl SpacetimeClient { &self, ) -> Result, SpacetimeClientError> { self.list_big_fish_works_with_input(BigFishWorksListInput { - owner_user_id: String::new(), + // 中文注释:公开广场读取只依赖 published_only,但旧部署模块会先校验 owner_user_id 非空。 + owner_user_id: PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID.to_string(), published_only: true, }) .await @@ -318,6 +322,29 @@ impl SpacetimeClient { .await } + pub async fn record_big_fish_like( + &self, + input: BigFishLikeReportRecordInput, + ) -> Result, SpacetimeClientError> { + let procedure_input = BigFishWorkLikeRecordInput { + session_id: input.session_id, + user_id: input.user_id, + liked_at_micros: input.liked_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .record_big_fish_like_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(|result| map_big_fish_works_procedure_result(result, None)); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn get_big_fish_run( &self, run_id: String, @@ -341,6 +368,31 @@ impl SpacetimeClient { .await } + pub async fn remix_big_fish_work( + &self, + input: BigFishWorkRemixRecordInput, + ) -> Result { + let procedure_input = BigFishWorkRemixInput { + source_session_id: input.source_session_id, + target_session_id: input.target_session_id, + target_owner_user_id: input.target_owner_user_id, + welcome_message_id: input.welcome_message_id, + remixed_at_micros: input.remixed_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .remix_big_fish_work_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_session_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn submit_big_fish_input( &self, input: BigFishInputSubmitRecordInput, diff --git a/server-rs/crates/spacetime-client/src/custom_world.rs b/server-rs/crates/spacetime-client/src/custom_world.rs index 01b78c6a..3cbeb56f 100644 --- a/server-rs/crates/spacetime-client/src/custom_world.rs +++ b/server-rs/crates/spacetime-client/src/custom_world.rs @@ -1,6 +1,9 @@ use super::*; use crate::mapper::*; use crate::module_bindings::delete_custom_world_agent_session_procedure::delete_custom_world_agent_session; +use crate::module_bindings::record_custom_world_profile_like_procedure::record_custom_world_profile_like; +use crate::module_bindings::record_custom_world_profile_play_procedure::record_custom_world_profile_play; +use crate::module_bindings::remix_custom_world_profile_procedure::remix_custom_world_profile; impl SpacetimeClient { pub async fn list_custom_world_profiles( @@ -209,6 +212,80 @@ impl SpacetimeClient { .await } + pub async fn remix_custom_world_profile( + &self, + input: CustomWorldProfileRemixRecordInput, + ) -> Result { + let procedure_input = CustomWorldProfileRemixInput { + source_owner_user_id: input.source_owner_user_id, + source_profile_id: input.source_profile_id, + target_owner_user_id: input.target_owner_user_id, + target_profile_id: input.target_profile_id, + author_display_name: input.author_display_name, + remixed_at_micros: input.remixed_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().remix_custom_world_profile_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn record_custom_world_profile_play( + &self, + input: CustomWorldProfilePlayReportRecordInput, + ) -> Result { + let procedure_input = CustomWorldProfilePlayRecordInput { + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + played_at_micros: input.played_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .record_custom_world_profile_play_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn record_custom_world_profile_like( + &self, + input: CustomWorldProfileLikeReportRecordInput, + ) -> Result { + let procedure_input = CustomWorldProfileLikeRecordInput { + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + user_id: input.user_id, + liked_at_micros: input.liked_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .record_custom_world_profile_like_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn publish_custom_world_world( &self, input: CustomWorldPublishWorldRecordInput, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 0eca24a1..362f367b 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -3,42 +3,54 @@ pub mod module_bindings; mod mapper; -pub(crate) use mapper::*; +use mapper::*; pub use mapper::{ AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, AiTextChunkRecord, BattleStateRecord, BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord, BigFishInputSubmitRecordInput, BigFishLevelBlueprintRecord, - BigFishMessageFinalizeRecordInput, BigFishMessageSubmitRecordInput, - BigFishPlayReportRecordInput, BigFishRunStartRecordInput, BigFishRuntimeEntityRecord, - BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord, BigFishSessionCreateRecordInput, - BigFishSessionRecord, BigFishVector2Record, BigFishWorkSummaryRecord, - CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput, - CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput, - CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput, - CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord, - CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, - CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, + BigFishLikeReportRecordInput, BigFishMessageFinalizeRecordInput, + BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRunStartRecordInput, + BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord, + BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record, + BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord, + CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, + CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, + CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput, + CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, + CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord, - CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, - CustomWorldPublishWorldRecord, CustomWorldPublishWorldRecordInput, - CustomWorldPublishedProfileCompileRecord, CustomWorldResultPreviewBlockerRecord, - CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, NpcBattleInteractionRecord, - NpcInteractionRecord, NpcStateRecord, PuzzleAgentMessageFinalizeRecordInput, - PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, - PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, - PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, + CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput, + CustomWorldProfileRemixRecordInput, CustomWorldProfileUpsertRecordInput, + CustomWorldPublishGateRecord, CustomWorldPublishWorldRecord, + CustomWorldPublishWorldRecordInput, CustomWorldPublishedProfileCompileRecord, + CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, + CustomWorldWorkSummaryRecord, Match3DAgentMessageFinalizeRecordInput, + Match3DAgentMessageRecord, Match3DAgentMessageSubmitRecordInput, + Match3DAgentSessionCreateRecordInput, Match3DAgentSessionRecord, Match3DAnchorItemRecord, + Match3DAnchorPackRecord, Match3DClickConfirmationRecord, Match3DCompileDraftRecordInput, + Match3DCreatorConfigRecord, Match3DItemSnapshotRecord, Match3DResultDraftRecord, + Match3DRunClickRecordInput, Match3DRunRecord, Match3DRunRestartRecordInput, + Match3DRunStartRecordInput, Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput, + Match3DTraySlotRecord, Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput, + NpcBattleInteractionRecord, NpcInteractionRecord, NpcStateRecord, + PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, + PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, + PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, + PuzzleAnchorPackRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, + PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, - PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, - PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunRecord, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, + PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, + PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, + PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, - PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, - ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, + PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, + PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, + PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, }; pub mod ai; @@ -48,6 +60,7 @@ pub mod big_fish; pub mod combat; pub mod custom_world; pub mod inventory; +pub mod match3d; pub mod npc; pub mod puzzle; pub mod runtime; @@ -109,7 +122,7 @@ use module_puzzle::{ PuzzleAnchorItem as DomainPuzzleAnchorItem, PuzzleAnchorPack as DomainPuzzleAnchorPack, PuzzleBoardSnapshot as DomainPuzzleBoardSnapshot, PuzzleCellPosition as DomainPuzzleCellPosition, - PuzzleCreatorIntent as DomainPuzzleCreatorIntent, + PuzzleCreatorIntent as DomainPuzzleCreatorIntent, PuzzleDraftLevel as DomainPuzzleDraftLevel, PuzzleGeneratedImageCandidate as DomainPuzzleGeneratedImageCandidate, PuzzleMergedGroupState as DomainPuzzleMergedGroupState, PuzzlePieceState as DomainPuzzlePieceState, PuzzleResultDraft as DomainPuzzleResultDraft, @@ -122,7 +135,7 @@ use module_puzzle::{ }; use module_runtime::{ RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, - RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord, + RuntimeProfileDashboardRecord, RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, @@ -131,7 +144,8 @@ use module_runtime::{ RuntimeSnapshotRecord, build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input, build_runtime_browse_history_record, build_runtime_browse_history_sync_input, build_runtime_profile_dashboard_get_input, - build_runtime_profile_dashboard_record, build_runtime_profile_play_stats_get_input, + build_runtime_profile_dashboard_record, build_runtime_profile_invite_code_admin_upsert_input, + build_runtime_profile_invite_code_record, build_runtime_profile_play_stats_get_input, build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_order_create_input, @@ -177,6 +191,7 @@ pub struct SpacetimeClientConfig { pub database: String, pub token: Option, pub pool_size: u32, + pub procedure_timeout: Duration, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -207,7 +222,7 @@ pub enum SpacetimeClientError { Timeout, } -const CONFIRM_ASSET_OBJECT_TIMEOUT: Duration = Duration::from_secs(10); +const DEFAULT_PROCEDURE_TIMEOUT: Duration = Duration::from_secs(30); type ProcedureResultSender = Arc>>>>; @@ -238,6 +253,14 @@ struct PooledConnectionLease { impl SpacetimeClient { pub fn new(config: SpacetimeClientConfig) -> Self { let pool_size = config.pool_size.max(1) as usize; + let config = SpacetimeClientConfig { + procedure_timeout: if config.procedure_timeout.is_zero() { + DEFAULT_PROCEDURE_TIMEOUT + } else { + config.procedure_timeout + }, + ..config + }; let slots = (0..pool_size) .map(|_| { tokio::sync::Mutex::new(PooledConnectionSlot { @@ -266,7 +289,7 @@ impl SpacetimeClient { let lease = self.acquire_connection().await?; let final_result = if let Some(connection) = lease.connection.as_ref() { call(&connection.connection, result_sender.clone()); - match timeout(CONFIRM_ASSET_OBJECT_TIMEOUT, receiver).await { + match timeout(self.config.procedure_timeout, receiver).await { Ok(inner) => match inner { Ok(value) => value, Err(_) => Err(SpacetimeClientError::ConnectDropped), @@ -292,7 +315,7 @@ impl SpacetimeClient { let lease = self.acquire_connection().await?; let final_result = if let Some(connection) = lease.connection.as_ref() { call(&connection.connection, result_sender.clone()); - match timeout(CONFIRM_ASSET_OBJECT_TIMEOUT, receiver).await { + match timeout(self.config.procedure_timeout, receiver).await { Ok(inner) => match inner { Ok(value) => value, Err(_) => Err(SpacetimeClientError::ConnectDropped), @@ -311,7 +334,7 @@ impl SpacetimeClient { async fn acquire_connection(&self) -> Result { let permit = timeout( - CONFIRM_ASSET_OBJECT_TIMEOUT, + self.config.procedure_timeout, self.pool.permits.clone().acquire_owned(), ) .await @@ -388,7 +411,7 @@ impl SpacetimeClient { .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))??; let runner = connection.run_threaded(); - timeout(CONFIRM_ASSET_OBJECT_TIMEOUT, receiver) + timeout(self.config.procedure_timeout, receiver) .await .map_err(|_| SpacetimeClientError::Timeout)? .map_err(|_| SpacetimeClientError::ConnectDropped)??; diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 162c6c4e..b64be155 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -203,6 +203,19 @@ impl From } } +impl From + for RuntimeProfileInviteCodeAdminUpsertInput +{ + fn from(input: module_runtime::RuntimeProfileInviteCodeAdminUpsertInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + invite_code: input.invite_code, + metadata_json: input.metadata_json, + updated_at_micros: input.updated_at_micros, + } + } +} + impl From for RuntimeReferralInviteCenterGetInput { @@ -804,6 +817,26 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result( )) } +pub(crate) fn map_runtime_profile_invite_code_admin_procedure_result( + result: RuntimeProfileInviteCodeAdminProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 invite code 快照".to_string()) + })?; + + Ok(build_runtime_profile_invite_code_record( + map_runtime_profile_invite_code_snapshot(snapshot), + )) +} + pub(crate) fn map_runtime_profile_play_stats_procedure_result( result: RuntimeProfilePlayStatsProcedureResult, ) -> Result { @@ -1169,7 +1202,6 @@ pub(crate) fn map_big_fish_works_procedure_result( result: BigFishWorksProcedureResult, fallback_owner_user_id: Option<&str>, ) -> Result, SpacetimeClientError> { - let _ = fallback_owner_user_id; if !result.ok { return Err(SpacetimeClientError::procedure_failed(result.error_message)); } @@ -1177,9 +1209,16 @@ pub(crate) fn map_big_fish_works_procedure_result( let items_json = result .items_json .ok_or_else(|| SpacetimeClientError::missing_snapshot("big fish works 快照"))?; - serde_json::from_str::>(&items_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}")) - }) + serde_json::from_str::>(&items_json) + .map(|items| { + items + .into_iter() + .map(|item| item.into_record(fallback_owner_user_id)) + .collect() + }) + .map_err(|error| { + SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}")) + }) } pub(crate) fn map_big_fish_run_procedure_result( @@ -1199,6 +1238,132 @@ pub(crate) fn map_big_fish_run_procedure_result( Ok(map_big_fish_runtime_snapshot(run)) } +pub(crate) fn map_match3d_agent_session_procedure_result( + result: Match3DAgentSessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let session_json = result.session_json.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 match3d agent session 快照".to_string(), + ) + })?; + let session = + serde_json::from_str::(&session_json).map_err(|error| { + SpacetimeClientError::Runtime(format!("match3d session_json 非法: {error}")) + })?; + + Ok(map_match3d_agent_session_snapshot(session)) +} + +pub(crate) fn map_match3d_work_procedure_result( + result: Match3DWorkProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let work_json = result.work_json.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 match3d work 快照".to_string(), + ) + })?; + let work = serde_json::from_str::(&work_json).map_err(|error| { + SpacetimeClientError::Runtime(format!("match3d work_json 非法: {error}")) + })?; + + Ok(map_match3d_work_snapshot(work)) +} + +pub(crate) fn map_match3d_works_procedure_result( + result: Match3DWorksProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let items_json = result.items_json.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 match3d works 快照".to_string(), + ) + })?; + let items = + serde_json::from_str::>(&items_json).map_err(|error| { + SpacetimeClientError::Runtime(format!("match3d works items_json 非法: {error}")) + })?; + + Ok(items.into_iter().map(map_match3d_work_snapshot).collect()) +} + +pub(crate) fn map_match3d_run_procedure_result( + result: Match3DRunProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let run_json = result.run_json.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 match3d run 快照".to_string()) + })?; + map_match3d_run_json(run_json) +} + +pub(crate) fn map_match3d_click_item_procedure_result( + result: Match3DClickItemProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let run_json = result.run_json.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 match3d click run 快照".to_string(), + ) + })?; + let run = map_match3d_run_json(run_json)?; + let accepted = result.status == "Accepted"; + let accepted_item_instance_id = result.accepted_item_instance_id.clone(); + let entered_slot_index = accepted_item_instance_id.as_deref().and_then(|item_id| { + run.items + .iter() + .find(|item| item.item_instance_id == item_id) + .and_then(|item| item.tray_slot_index) + }); + + Ok(Match3DClickConfirmationRecord { + status: result.status.clone(), + accepted, + reject_reason: if accepted { None } else { Some(result.status) }, + accepted_item_instance_id, + entered_slot_index, + cleared_item_instance_ids: result.cleared_item_instance_ids, + failure_reason: result.failure_reason, + run, + }) +} + pub(crate) fn map_story_session_procedure_result( result: StorySessionProcedureResult, ) -> Result { @@ -1517,6 +1682,16 @@ pub(crate) fn map_runtime_referral_invite_center_snapshot( today_inviter_reward_count: snapshot.today_inviter_reward_count, today_inviter_reward_remaining: snapshot.today_inviter_reward_remaining, reward_points: snapshot.reward_points, + invited_users: snapshot + .invited_users + .into_iter() + .map(|user| module_runtime::RuntimeReferralInvitedUserSnapshot { + user_id: user.user_id, + display_name: user.display_name, + avatar_url: user.avatar_url, + bound_at_micros: user.bound_at_micros, + }) + .collect(), has_redeemed_code: snapshot.has_redeemed_code, bound_inviter_user_id: snapshot.bound_inviter_user_id, bound_at_micros: snapshot.bound_at_micros, @@ -1563,6 +1738,18 @@ pub(crate) fn map_runtime_profile_redeem_code_snapshot( } } +pub(crate) fn map_runtime_profile_invite_code_snapshot( + snapshot: RuntimeProfileInviteCodeSnapshot, +) -> module_runtime::RuntimeProfileInviteCodeSnapshot { + module_runtime::RuntimeProfileInviteCodeSnapshot { + user_id: snapshot.user_id, + invite_code: snapshot.invite_code, + metadata_json: snapshot.metadata_json, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + pub(crate) fn map_runtime_profile_played_world_snapshot( snapshot: RuntimeProfilePlayedWorldSnapshot, ) -> module_runtime::RuntimeProfilePlayedWorldSnapshot { @@ -1664,6 +1851,10 @@ pub(crate) fn map_custom_world_library_entry_from_profile_snapshot( .to_string(), playable_npc_count: snapshot.playable_npc_count, landmark_count: snapshot.landmark_count, + play_count: snapshot.play_count, + remix_count: snapshot.remix_count, + like_count: snapshot.like_count, + recent_play_count_7d: 0, }) } @@ -1689,6 +1880,10 @@ pub(crate) fn map_custom_world_gallery_entry_snapshot( .to_string(), playable_npc_count: snapshot.playable_npc_count, landmark_count: snapshot.landmark_count, + play_count: snapshot.play_count, + remix_count: snapshot.remix_count, + like_count: snapshot.like_count, + recent_play_count_7d: snapshot.recent_play_count_7_d, }) } @@ -1994,6 +2189,7 @@ pub(crate) fn map_puzzle_agent_session_snapshot( ) -> PuzzleAgentSessionRecord { PuzzleAgentSessionRecord { session_id: snapshot.session_id, + seed_text: snapshot.seed_text, current_turn: snapshot.current_turn, progress_percent: snapshot.progress_percent, stage: snapshot.stage.as_str().to_string(), @@ -2039,6 +2235,8 @@ pub(crate) fn map_puzzle_result_draft( snapshot: DomainPuzzleResultDraft, ) -> PuzzleResultDraftRecord { PuzzleResultDraftRecord { + work_title: snapshot.work_title, + work_description: snapshot.work_description, level_name: snapshot.level_name, summary: snapshot.summary, theme_tags: snapshot.theme_tags, @@ -2054,6 +2252,39 @@ pub(crate) fn map_puzzle_result_draft( cover_image_src: snapshot.cover_image_src, cover_asset_id: snapshot.cover_asset_id, generation_status: snapshot.generation_status, + levels: snapshot + .levels + .into_iter() + .map(map_puzzle_draft_level) + .collect(), + form_draft: snapshot.form_draft.map(map_puzzle_form_draft), + } +} + +pub(crate) fn map_puzzle_form_draft( + snapshot: module_puzzle::PuzzleFormDraft, +) -> PuzzleFormDraftRecord { + PuzzleFormDraftRecord { + work_title: snapshot.work_title, + work_description: snapshot.work_description, + picture_description: snapshot.picture_description, + } +} + +pub(crate) fn map_puzzle_draft_level(snapshot: DomainPuzzleDraftLevel) -> PuzzleDraftLevelRecord { + PuzzleDraftLevelRecord { + level_id: snapshot.level_id, + level_name: snapshot.level_name, + picture_description: snapshot.picture_description, + candidates: snapshot + .candidates + .into_iter() + .map(map_puzzle_generated_image_candidate) + .collect(), + selected_candidate_id: snapshot.selected_candidate_id, + cover_image_src: snapshot.cover_image_src, + cover_asset_id: snapshot.cover_asset_id, + generation_status: snapshot.generation_status, } } @@ -2098,6 +2329,236 @@ pub(crate) fn map_puzzle_agent_message_snapshot( } } +fn map_match3d_agent_session_snapshot( + snapshot: Match3DAgentSessionJsonRecord, +) -> Match3DAgentSessionRecord { + let config = map_match3d_creator_config(snapshot.config); + Match3DAgentSessionRecord { + session_id: snapshot.session_id, + current_turn: snapshot.current_turn, + progress_percent: snapshot.progress_percent, + stage: normalize_match3d_stage(&snapshot.stage).to_string(), + anchor_pack: build_match3d_anchor_pack(&config), + draft: snapshot + .draft + .map(|draft| map_match3d_result_draft(draft, config.reference_image_src.clone())), + config: Some(config), + messages: snapshot + .messages + .into_iter() + .map(map_match3d_agent_message_snapshot) + .collect(), + last_assistant_reply: empty_string_to_none(snapshot.last_assistant_reply), + published_profile_id: snapshot.published_profile_id, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn map_match3d_creator_config( + snapshot: Match3DCreatorConfigJsonRecord, +) -> Match3DCreatorConfigRecord { + Match3DCreatorConfigRecord { + theme_text: snapshot.theme_text, + reference_image_src: snapshot.reference_image_src, + clear_count: snapshot.clear_count, + difficulty: snapshot.difficulty, + } +} + +fn map_match3d_result_draft( + snapshot: Match3DDraftJsonRecord, + reference_image_src: Option, +) -> Match3DResultDraftRecord { + Match3DResultDraftRecord { + profile_id: snapshot.profile_id, + game_name: snapshot.game_name, + theme_text: snapshot.theme_text, + summary_text: snapshot.summary_text, + tags: snapshot.tags, + cover_image_src: None, + reference_image_src, + clear_count: snapshot.clear_count, + difficulty: snapshot.difficulty, + total_item_count: snapshot.clear_count.saturating_mul(3), + publish_ready: false, + blockers: Vec::new(), + } +} + +fn map_match3d_agent_message_snapshot( + snapshot: Match3DAgentMessageJsonRecord, +) -> Match3DAgentMessageRecord { + Match3DAgentMessageRecord { + message_id: snapshot.message_id, + role: snapshot.role, + kind: normalize_match3d_message_kind(&snapshot.kind).to_string(), + text: snapshot.text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +fn map_match3d_work_snapshot(snapshot: Match3DWorkJsonRecord) -> Match3DWorkProfileRecord { + let config = map_match3d_creator_config(snapshot.config); + Match3DWorkProfileRecord { + work_id: snapshot.profile_id.clone(), + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + source_session_id: empty_string_to_none(snapshot.source_session_id), + author_display_name: snapshot.author_display_name, + game_name: snapshot.game_name, + theme_text: snapshot.theme_text, + summary: snapshot.summary_text, + tags: snapshot.tags, + cover_image_src: empty_string_to_none(snapshot.cover_image_src), + cover_asset_id: empty_string_to_none(snapshot.cover_asset_id), + reference_image_src: config.reference_image_src, + clear_count: snapshot.clear_count, + difficulty: snapshot.difficulty, + publication_status: normalize_match3d_publication_status(&snapshot.publication_status) + .to_string(), + play_count: snapshot.play_count, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + publish_ready: snapshot.publish_ready, + } +} + +fn map_match3d_run_json(run_json: String) -> Result { + let run = serde_json::from_str::(&run_json).map_err(|error| { + SpacetimeClientError::Runtime(format!("match3d run_json 非法: {error}")) + })?; + Ok(map_match3d_run_snapshot(run)) +} + +fn map_match3d_run_snapshot(snapshot: Match3DRunJsonRecord) -> Match3DRunRecord { + let tray_slots = snapshot + .tray_slots + .into_iter() + .map(map_match3d_tray_slot_snapshot) + .collect::>(); + let items = snapshot + .items + .into_iter() + .map(|item| { + let tray_slot_index = tray_slots + .iter() + .find(|slot| { + slot.item_instance_id.as_deref() == Some(item.item_instance_id.as_str()) + }) + .map(|slot| slot.slot_index); + map_match3d_item_snapshot(item, tray_slot_index) + }) + .collect(); + + Match3DRunRecord { + run_id: snapshot.run_id, + profile_id: snapshot.profile_id, + owner_user_id: String::new(), + status: snapshot.status, + snapshot_version: u64::from(snapshot.snapshot_version), + started_at_ms: i64_to_u64_ms(snapshot.started_at_ms), + duration_limit_ms: i64_to_u64_ms(snapshot.duration_limit_ms), + server_now_ms: Some(i64_to_u64_ms(snapshot.server_now_ms)), + remaining_ms: i64_to_u64_ms(snapshot.remaining_ms), + clear_count: snapshot.clear_count, + total_item_count: snapshot.total_item_count, + cleared_item_count: snapshot.cleared_item_count, + items, + tray_slots, + failure_reason: snapshot.failure_reason, + last_confirmed_action_id: None, + } +} + +fn map_match3d_item_snapshot( + snapshot: Match3DItemJsonRecord, + tray_slot_index: Option, +) -> Match3DItemSnapshotRecord { + Match3DItemSnapshotRecord { + item_instance_id: snapshot.item_instance_id, + item_type_id: snapshot.item_type_id, + visual_key: snapshot.visual_key, + x: snapshot.x, + y: snapshot.y, + radius: snapshot.radius, + layer: snapshot.layer, + state: snapshot.state, + clickable: snapshot.clickable, + tray_slot_index, + } +} + +fn map_match3d_tray_slot_snapshot(snapshot: Match3DTraySlotJsonRecord) -> Match3DTraySlotRecord { + Match3DTraySlotRecord { + slot_index: snapshot.slot_index, + item_instance_id: snapshot.item_instance_id, + item_type_id: snapshot.item_type_id, + visual_key: snapshot.visual_key, + } +} + +fn build_match3d_anchor_pack(config: &Match3DCreatorConfigRecord) -> Match3DAnchorPackRecord { + let clear_count = config.clear_count.to_string(); + let difficulty = config.difficulty.to_string(); + Match3DAnchorPackRecord { + theme: build_match3d_anchor_item("theme", "题材主题", config.theme_text.as_str()), + clear_count: build_match3d_anchor_item("clearCount", "需要消除次数", clear_count.as_str()), + difficulty: build_match3d_anchor_item("difficulty", "难度", difficulty.as_str()), + } +} + +fn build_match3d_anchor_item(key: &str, label: &str, value: &str) -> Match3DAnchorItemRecord { + Match3DAnchorItemRecord { + key: key.to_string(), + label: label.to_string(), + value: value.to_string(), + status: if value.trim().is_empty() { + "missing" + } else { + "confirmed" + } + .to_string(), + } +} + +fn normalize_match3d_stage(value: &str) -> &str { + match value { + "Collecting" | "collecting" | "collecting_config" => "collecting_config", + "ReadyToCompile" | "ready_to_compile" => "ready_to_compile", + "DraftCompiled" | "draft_compiled" | "draft_ready" => "draft_ready", + "Published" | "published" => "published", + _ => value, + } +} + +fn normalize_match3d_publication_status(value: &str) -> &str { + match value { + "Draft" | "draft" => "draft", + "Published" | "published" => "published", + _ => value, + } +} + +fn normalize_match3d_message_kind(value: &str) -> &str { + match value { + "text" => "chat", + _ => value, + } +} + +fn empty_string_to_none(value: String) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn i64_to_u64_ms(value: i64) -> u64 { + value.max(0) as u64 +} + pub(crate) fn map_puzzle_suggested_action( snapshot: DomainPuzzleAgentSuggestedAction, ) -> PuzzleAgentSuggestedActionRecord { @@ -2157,6 +2618,8 @@ pub(crate) fn map_puzzle_work_profile( owner_user_id: snapshot.owner_user_id, source_session_id: snapshot.source_session_id, author_display_name: snapshot.author_display_name, + work_title: snapshot.work_title, + work_description: snapshot.work_description, level_name: snapshot.level_name, summary: snapshot.summary, theme_tags: snapshot.theme_tags, @@ -2166,8 +2629,18 @@ pub(crate) fn map_puzzle_work_profile( updated_at: format_timestamp_micros(snapshot.updated_at_micros), published_at: snapshot.published_at_micros.map(format_timestamp_micros), play_count: snapshot.play_count, + remix_count: snapshot.remix_count, + like_count: snapshot.like_count, + recent_play_count_7d: snapshot.recent_play_count_7d, + point_incentive_total_half_points: snapshot.point_incentive_total_half_points, + point_incentive_claimed_points: snapshot.point_incentive_claimed_points, publish_ready: snapshot.publish_ready, anchor_pack: map_puzzle_anchor_pack(snapshot.anchor_pack), + levels: snapshot + .levels + .into_iter() + .map(map_puzzle_draft_level) + .collect(), } } @@ -2184,6 +2657,14 @@ pub(crate) fn map_puzzle_run_snapshot(snapshot: DomainPuzzleRunSnapshot) -> Puzz .current_level .map(map_puzzle_runtime_level_snapshot), recommended_next_profile_id: snapshot.recommended_next_profile_id, + next_level_mode: snapshot.next_level_mode, + next_level_profile_id: snapshot.next_level_profile_id, + next_level_id: snapshot.next_level_id, + recommended_next_works: snapshot + .recommended_next_works + .into_iter() + .map(map_puzzle_recommended_next_work) + .collect(), leaderboard_entries: snapshot .leaderboard_entries .into_iter() @@ -2192,12 +2673,33 @@ pub(crate) fn map_puzzle_run_snapshot(snapshot: DomainPuzzleRunSnapshot) -> Puzz } } +fn map_puzzle_recommended_next_work( + snapshot: module_puzzle::PuzzleRecommendedNextWork, +) -> PuzzleRecommendedNextWorkRecord { + PuzzleRecommendedNextWorkRecord { + profile_id: snapshot.profile_id, + level_name: snapshot.level_name, + author_display_name: snapshot.author_display_name, + theme_tags: snapshot.theme_tags, + cover_image_src: snapshot.cover_image_src, + similarity_score: snapshot.similarity_score, + } +} + pub(crate) fn map_puzzle_runtime_level_snapshot( snapshot: DomainPuzzleRuntimeLevelSnapshot, ) -> PuzzleRuntimeLevelRecord { + let started_at_ms = if snapshot.started_at_ms == 0 { + // 中文注释:旧 run_json 没有计时字段时只补一个可用开始时间,其余限时字段保持旧默认值。 + current_unix_millis_for_legacy_puzzle_snapshot() + } else { + snapshot.started_at_ms + }; + PuzzleRuntimeLevelRecord { run_id: snapshot.run_id, level_index: snapshot.level_index, + level_id: snapshot.level_id, grid_size: snapshot.grid_size, profile_id: snapshot.profile_id, level_name: snapshot.level_name, @@ -2206,9 +2708,16 @@ pub(crate) fn map_puzzle_runtime_level_snapshot( cover_image_src: snapshot.cover_image_src, board: map_puzzle_board_snapshot(snapshot.board), status: snapshot.status.as_str().to_string(), - started_at_ms: snapshot.started_at_ms, + started_at_ms, cleared_at_ms: snapshot.cleared_at_ms, elapsed_ms: snapshot.elapsed_ms, + time_limit_ms: snapshot.time_limit_ms, + remaining_ms: snapshot.remaining_ms, + paused_accumulated_ms: snapshot.paused_accumulated_ms, + pause_started_at_ms: snapshot.pause_started_at_ms, + freeze_accumulated_ms: snapshot.freeze_accumulated_ms, + freeze_started_at_ms: snapshot.freeze_started_at_ms, + freeze_until_ms: snapshot.freeze_until_ms, leaderboard_entries: snapshot .leaderboard_entries .into_iter() @@ -2217,6 +2726,13 @@ pub(crate) fn map_puzzle_runtime_level_snapshot( } } +fn current_unix_millis_for_legacy_puzzle_snapshot() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or(1) +} + pub(crate) fn map_puzzle_leaderboard_entry( snapshot: module_puzzle::PuzzleLeaderboardEntry, ) -> PuzzleLeaderboardEntryRecord { @@ -3210,6 +3726,9 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( crate::module_bindings::RuntimeProfileWalletLedgerSourceType::SnapshotSync => { module_runtime::RuntimeProfileWalletLedgerSourceType::SnapshotSync } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward => { + module_runtime::RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward + } crate::module_bindings::RuntimeProfileWalletLedgerSourceType::InviteInviterReward => { module_runtime::RuntimeProfileWalletLedgerSourceType::InviteInviterReward } @@ -3228,6 +3747,9 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => { module_runtime::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => { + module_runtime::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim + } } } @@ -3763,6 +4285,10 @@ pub struct CustomWorldLibraryEntryRecord { pub theme_mode: String, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub recent_play_count_7d: u32, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -3782,6 +4308,10 @@ pub struct CustomWorldGalleryEntryRecord { pub theme_mode: String, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub recent_play_count_7d: u32, } #[derive(Clone, Debug, PartialEq)] @@ -3998,6 +4528,31 @@ pub struct CustomWorldProfileUpsertRecordInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldProfileRemixRecordInput { + pub source_owner_user_id: String, + pub source_profile_id: String, + pub target_owner_user_id: String, + pub target_profile_id: String, + pub author_display_name: String, + pub remixed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldProfilePlayReportRecordInput { + pub owner_user_id: String, + pub profile_id: String, + pub played_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldProfileLikeReportRecordInput { + pub owner_user_id: String, + pub profile_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CustomWorldPublishWorldRecordInput { pub session_id: String, @@ -4097,6 +4652,14 @@ pub struct PuzzleAgentSessionCreateRecordInput { pub created_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleFormDraftSaveRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub saved_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleAgentMessageSubmitRecordInput { pub session_id: String, @@ -4123,6 +4686,8 @@ pub struct PuzzleAgentMessageFinalizeRecordInput { pub struct PuzzleGeneratedImagesSaveRecordInput { pub session_id: String, pub owner_user_id: String, + pub level_id: Option, + pub levels_json: Option, pub candidates_json: String, pub saved_at_micros: i64, } @@ -4131,6 +4696,7 @@ pub struct PuzzleGeneratedImagesSaveRecordInput { pub struct PuzzleSelectCoverImageRecordInput { pub session_id: String, pub owner_user_id: String, + pub level_id: Option, pub candidate_id: String, pub selected_at_micros: i64, } @@ -4142,9 +4708,12 @@ pub struct PuzzlePublishRecordInput { pub work_id: String, pub profile_id: String, pub author_display_name: String, + pub work_title: Option, + pub work_description: Option, pub level_name: Option, pub summary: Option, pub theme_tags: Option>, + pub levels_json: Option, pub published_at_micros: i64, } @@ -4152,19 +4721,42 @@ pub struct PuzzlePublishRecordInput { pub struct PuzzleWorkUpsertRecordInput { pub profile_id: String, pub owner_user_id: String, + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, pub cover_image_src: Option, pub cover_asset_id: Option, + pub levels_json: Option, pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleWorkRemixRecordInput { + pub source_profile_id: String, + pub target_owner_user_id: String, + pub target_session_id: String, + pub target_profile_id: String, + pub target_work_id: String, + pub author_display_name: String, + pub welcome_message_id: String, + pub remixed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleWorkLikeReportRecordInput { + pub profile_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleRunStartRecordInput { pub run_id: String, pub owner_user_id: String, pub profile_id: String, + pub level_id: Option, pub started_at_micros: i64, } @@ -4194,6 +4786,23 @@ pub struct PuzzleRunNextLevelRecordInput { pub advanced_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunPauseRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub paused: bool, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunPropRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub prop_kind: String, + pub used_at_micros: i64, + pub spent_points: u64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct BigFishPlayReportRecordInput { pub session_id: String, @@ -4219,6 +4828,383 @@ pub struct BigFishInputSubmitRecordInput { pub submitted_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishLikeReportRecordInput { + pub session_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishWorkRemixRecordInput { + pub source_session_id: String, + pub target_session_id: String, + pub target_owner_user_id: String, + pub welcome_message_id: String, + pub remixed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DAgentSessionCreateRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub config_json: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DAgentMessageSubmitRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DAgentMessageFinalizeRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub config_json: Option, + pub progress_percent: u32, + pub stage: String, + pub updated_at_micros: i64, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DCompileDraftRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub author_display_name: String, + pub game_name: Option, + pub summary_text: Option, + pub tags_json: Option, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub compiled_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DWorkUpdateRecordInput { + pub profile_id: String, + pub owner_user_id: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags_json: String, + pub cover_image_src: String, + pub cover_asset_id: String, + pub clear_count: u32, + pub difficulty: u32, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DRunStartRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub started_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DRunClickRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub item_instance_id: String, + pub client_snapshot_version: u32, + pub client_event_id: String, + pub clicked_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DRunStopRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub stopped_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DRunRestartRecordInput { + pub source_run_id: String, + pub next_run_id: String, + pub owner_user_id: String, + pub restarted_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DRunTimeUpRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub finished_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DAnchorItemRecord { + pub key: String, + pub label: String, + pub value: String, + pub status: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DAnchorPackRecord { + pub theme: Match3DAnchorItemRecord, + pub clear_count: Match3DAnchorItemRecord, + pub difficulty: Match3DAnchorItemRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DCreatorConfigRecord { + pub theme_text: String, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DResultDraftRecord { + pub profile_id: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags: Vec, + pub cover_image_src: Option, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub total_item_count: u32, + pub publish_ready: bool, + pub blockers: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DAgentMessageRecord { + pub message_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DAgentSessionRecord { + pub session_id: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub anchor_pack: Match3DAnchorPackRecord, + pub config: Option, + pub draft: Option, + pub messages: Vec, + pub last_assistant_reply: Option, + pub published_profile_id: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DWorkProfileRecord { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub author_display_name: String, + pub game_name: String, + pub theme_text: String, + pub summary: String, + pub tags: Vec, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publication_status: String, + pub play_count: u32, + pub updated_at: String, + pub published_at: Option, + pub publish_ready: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Match3DItemSnapshotRecord { + pub item_instance_id: String, + pub item_type_id: String, + pub visual_key: String, + pub x: f32, + pub y: f32, + pub radius: f32, + pub layer: u32, + pub state: String, + pub clickable: bool, + pub tray_slot_index: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DTraySlotRecord { + pub slot_index: u32, + pub item_instance_id: Option, + pub item_type_id: Option, + pub visual_key: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Match3DRunRecord { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: String, + pub snapshot_version: u64, + pub started_at_ms: u64, + pub duration_limit_ms: u64, + pub server_now_ms: Option, + pub remaining_ms: u64, + pub clear_count: u32, + pub total_item_count: u32, + pub cleared_item_count: u32, + pub items: Vec, + pub tray_slots: Vec, + pub failure_reason: Option, + pub last_confirmed_action_id: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Match3DClickConfirmationRecord { + pub status: String, + pub accepted: bool, + pub reject_reason: Option, + pub accepted_item_instance_id: Option, + pub entered_slot_index: Option, + pub cleared_item_instance_ids: Vec, + pub failure_reason: Option, + pub run: Match3DRunRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct Match3DCreatorConfigJsonRecord { + theme_text: String, + reference_image_src: Option, + clear_count: u32, + difficulty: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct Match3DAgentMessageJsonRecord { + message_id: String, + #[allow(dead_code)] + session_id: String, + role: String, + kind: String, + text: String, + created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct Match3DDraftJsonRecord { + profile_id: String, + game_name: String, + theme_text: String, + summary_text: String, + tags: Vec, + clear_count: u32, + difficulty: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct Match3DAgentSessionJsonRecord { + session_id: String, + #[allow(dead_code)] + owner_user_id: String, + #[allow(dead_code)] + seed_text: String, + current_turn: u32, + progress_percent: u32, + stage: String, + config: Match3DCreatorConfigJsonRecord, + draft: Option, + messages: Vec, + last_assistant_reply: String, + published_profile_id: Option, + #[allow(dead_code)] + created_at_micros: i64, + updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct Match3DWorkJsonRecord { + profile_id: String, + owner_user_id: String, + source_session_id: String, + author_display_name: String, + game_name: String, + theme_text: String, + summary_text: String, + tags: Vec, + cover_image_src: String, + cover_asset_id: String, + clear_count: u32, + difficulty: u32, + config: Match3DCreatorConfigJsonRecord, + publication_status: String, + publish_ready: bool, + play_count: u32, + updated_at_micros: i64, + published_at_micros: Option, +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct Match3DItemJsonRecord { + item_instance_id: String, + item_type_id: String, + visual_key: String, + x: f32, + y: f32, + radius: f32, + layer: u32, + state: String, + clickable: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct Match3DTraySlotJsonRecord { + slot_index: u32, + item_instance_id: Option, + item_type_id: Option, + visual_key: Option, +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct Match3DRunJsonRecord { + run_id: String, + profile_id: String, + status: String, + snapshot_version: u32, + started_at_ms: i64, + duration_limit_ms: i64, + server_now_ms: i64, + remaining_ms: i64, + clear_count: u32, + total_item_count: u32, + cleared_item_count: u32, + tray_slots: Vec, + items: Vec, + failure_reason: Option, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleAnchorItemRecord { pub key: String, @@ -4261,6 +5247,8 @@ pub struct PuzzleGeneratedImageCandidateRecord { #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleResultDraftRecord { + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, @@ -4272,6 +5260,27 @@ pub struct PuzzleResultDraftRecord { pub cover_image_src: Option, pub cover_asset_id: Option, pub generation_status: String, + pub levels: Vec, + pub form_draft: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleFormDraftRecord { + pub work_title: Option, + pub work_description: Option, + pub picture_description: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleDraftLevelRecord { + pub level_id: String, + pub level_name: String, + pub picture_description: String, + pub candidates: Vec, + pub selected_candidate_id: Option, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub generation_status: String, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -4316,6 +5325,7 @@ pub struct PuzzleResultPreviewRecord { #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleAgentSessionRecord { pub session_id: String, + pub seed_text: String, pub current_turn: u32, pub progress_percent: u32, pub stage: String, @@ -4336,6 +5346,8 @@ pub struct PuzzleWorkProfileRecord { pub owner_user_id: String, pub source_session_id: Option, pub author_display_name: String, + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, @@ -4345,8 +5357,21 @@ pub struct PuzzleWorkProfileRecord { pub updated_at: String, pub published_at: Option, pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub recent_play_count_7d: u32, + pub point_incentive_total_half_points: u64, + pub point_incentive_claimed_points: u64, pub publish_ready: bool, pub anchor_pack: PuzzleAnchorPackRecord, + pub levels: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleWorkPointIncentiveClaimRecordInput { + pub profile_id: String, + pub owner_user_id: String, + pub claimed_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -4390,10 +5415,21 @@ pub struct PuzzleBoardRecord { pub all_tiles_resolved: bool, } +#[derive(Clone, Debug, PartialEq)] +pub struct PuzzleRecommendedNextWorkRecord { + pub profile_id: String, + pub level_name: String, + pub author_display_name: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub similarity_score: f32, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleRuntimeLevelRecord { pub run_id: String, pub level_index: u32, + pub level_id: Option, pub grid_size: u32, pub profile_id: String, pub level_name: String, @@ -4405,10 +5441,17 @@ pub struct PuzzleRuntimeLevelRecord { pub started_at_ms: u64, pub cleared_at_ms: Option, pub elapsed_ms: Option, + pub time_limit_ms: u64, + pub remaining_ms: u64, + pub paused_accumulated_ms: u64, + pub pause_started_at_ms: Option, + pub freeze_accumulated_ms: u64, + pub freeze_started_at_ms: Option, + pub freeze_until_ms: Option, pub leaderboard_entries: Vec, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct PuzzleRunRecord { pub run_id: String, pub entry_profile_id: String, @@ -4419,6 +5462,10 @@ pub struct PuzzleRunRecord { pub previous_level_tags: Vec, pub current_level: Option, pub recommended_next_profile_id: Option, + pub next_level_mode: String, + pub next_level_profile_id: Option, + pub next_level_id: Option, + pub recommended_next_works: Vec, pub leaderboard_entries: Vec, } @@ -4646,12 +5693,276 @@ pub struct BigFishWorkSummaryRecord { pub cover_image_src: Option, pub status: String, pub updated_at_micros: i64, + pub published_at_micros: Option, pub publish_ready: bool, pub level_count: u32, pub level_main_image_ready_count: u32, pub level_motion_ready_count: u32, pub background_ready: bool, pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub recent_play_count_7d: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +struct CompatibleBigFishWorkSummaryRecord { + work_id: String, + source_session_id: String, + #[serde(default)] + owner_user_id: Option, + title: String, + subtitle: String, + summary: String, + cover_image_src: Option, + status: String, + updated_at_micros: i64, + #[serde(default)] + published_at_micros: Option, + publish_ready: bool, + level_count: u32, + level_main_image_ready_count: u32, + level_motion_ready_count: u32, + background_ready: bool, + #[serde(default)] + play_count: u32, + #[serde(default)] + remix_count: u32, + #[serde(default)] + like_count: u32, + #[serde(default)] + recent_play_count_7d: u32, +} + +impl CompatibleBigFishWorkSummaryRecord { + fn into_record(self, fallback_owner_user_id: Option<&str>) -> BigFishWorkSummaryRecord { + BigFishWorkSummaryRecord { + work_id: self.work_id, + source_session_id: self.source_session_id, + // 中文注释:兼容旧 works JSON 没有 owner_user_id 的历史数据,避免一次字段升级把整个作品列表打崩。 + owner_user_id: self.owner_user_id.unwrap_or_else(|| { + fallback_owner_user_id + .map(str::to_string) + .unwrap_or_default() + }), + title: self.title, + subtitle: self.subtitle, + summary: self.summary, + cover_image_src: self.cover_image_src, + status: self.status, + updated_at_micros: self.updated_at_micros, + published_at_micros: self.published_at_micros, + publish_ready: self.publish_ready, + level_count: self.level_count, + level_main_image_ready_count: self.level_main_image_ready_count, + level_motion_ready_count: self.level_motion_ready_count, + background_ready: self.background_ready, + play_count: self.play_count, + remix_count: self.remix_count, + like_count: self.like_count, + recent_play_count_7d: self.recent_play_count_7d, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn puzzle_works_mapper_backfills_missing_public_stat_fields() { + let result = PuzzleWorksProcedureResult { + ok: true, + items_json: Some( + r#"[{ + "work_id":"puzzle-work-1", + "profile_id":"puzzle-profile-1", + "owner_user_id":"user-1", + "source_session_id":null, + "author_display_name":"测试作者", + "level_name":"雨夜拼图", + "summary":"旧公开作品摘要", + "theme_tags":["雨夜","猫咪","神庙"], + "cover_image_src":null, + "cover_asset_id":null, + "publication_status":"Published", + "updated_at_micros":123000000, + "published_at_micros":123000000, + "publish_ready":true, + "anchor_pack":{ + "theme_promise":{ + "key":"themePromise", + "label":"题材承诺", + "value":"雨夜冒险", + "status":"Inferred" + }, + "visual_subject":{ + "key":"visualSubject", + "label":"画面主体", + "value":"猫咪神庙", + "status":"Inferred" + }, + "visual_mood":{ + "key":"visualMood", + "label":"视觉气质", + "value":"温暖", + "status":"Inferred" + }, + "composition_hooks":{ + "key":"compositionHooks", + "label":"拼图记忆点", + "value":"灯光", + "status":"Inferred" + }, + "tags_and_forbidden":{ + "key":"tagsAndForbidden", + "label":"标签与禁忌", + "value":"雨夜, 猫咪, 神庙", + "status":"Inferred" + } + } + }]"# + .to_string(), + ), + error_message: None, + }; + + let items = map_puzzle_works_procedure_result(result) + .expect("旧 puzzle works JSON 缺统计字段时应按 0 兼容"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].play_count, 0); + assert_eq!(items[0].remix_count, 0); + assert_eq!(items[0].like_count, 0); + } + + #[test] + fn puzzle_run_mapper_backfills_missing_timer_fields() { + let result = PuzzleRunProcedureResult { + ok: true, + run_json: Some( + r#"{ + "run_id":"puzzle-run-1", + "entry_profile_id":"puzzle-profile-1", + "cleared_level_count":0, + "current_level_index":1, + "current_grid_size":3, + "played_profile_ids":["puzzle-profile-1"], + "previous_level_tags":["雨夜","猫咪","神庙"], + "current_level":{ + "run_id":"puzzle-run-1", + "level_index":1, + "grid_size":3, + "profile_id":"puzzle-profile-1", + "level_name":"雨夜拼图", + "author_display_name":"测试作者", + "theme_tags":["雨夜","猫咪","神庙"], + "cover_image_src":null, + "board":{ + "rows":3, + "cols":3, + "pieces":[{ + "piece_id":"piece-1", + "correct_row":0, + "correct_col":0, + "current_row":0, + "current_col":0, + "merged_group_id":null + }], + "merged_groups":[], + "selected_piece_id":null + }, + "status":"Playing" + }, + "recommended_next_profile_id":null + }"# + .to_string(), + ), + error_message: None, + }; + + let run = map_puzzle_run_procedure_result(result) + .expect("旧 puzzle run JSON 缺计时字段时应按默认值兼容"); + let level = run.current_level.expect("兼容后仍应保留当前关卡"); + + assert_eq!(run.run_id, "puzzle-run-1"); + assert!(level.started_at_ms > 0); + assert_eq!(level.time_limit_ms, 0); + assert_eq!(level.remaining_ms, 0); + assert!(level.leaderboard_entries.is_empty()); + } + + #[test] + fn big_fish_works_mapper_backfills_missing_owner_user_id_for_private_lists() { + let result = BigFishWorksProcedureResult { + ok: true, + items_json: Some( + r#"[{ + "work_id":"big-fish-work-session-1", + "source_session_id":"session-1", + "title":"深海草稿", + "subtitle":"副标题", + "summary":"摘要", + "cover_image_src":null, + "status":"draft", + "updated_at_micros":123, + "publish_ready":false, + "level_count":8, + "level_main_image_ready_count":0, + "level_motion_ready_count":0, + "background_ready":false + }]"# + .to_string(), + ), + error_message: None, + }; + + let items = map_big_fish_works_procedure_result(result, Some("user-1")) + .expect("旧 works JSON 应能被兼容解析"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].owner_user_id, "user-1"); + assert_eq!(items[0].published_at_micros, None); + assert_eq!(items[0].play_count, 0); + assert_eq!(items[0].remix_count, 0); + assert_eq!(items[0].like_count, 0); + } + + #[test] + fn big_fish_works_mapper_keeps_empty_owner_when_gallery_legacy_json_lacks_field() { + let result = BigFishWorksProcedureResult { + ok: true, + items_json: Some( + r#"[{ + "work_id":"big-fish-work-session-2", + "source_session_id":"session-2", + "title":"公开作品", + "subtitle":"副标题", + "summary":"摘要", + "cover_image_src":null, + "status":"published", + "updated_at_micros":456, + "publish_ready":true, + "level_count":8, + "level_main_image_ready_count":8, + "level_motion_ready_count":16, + "background_ready":true + }]"# + .to_string(), + ), + error_message: None, + }; + + let items = map_big_fish_works_procedure_result(result, None) + .expect("公开 works 旧 JSON 也不应因缺字段报错"); + + assert_eq!(items.len(), 1); + assert!(items[0].owner_user_id.is_empty()); + assert_eq!(items[0].published_at_micros, None); + assert_eq!(items[0].play_count, 0); + assert_eq!(items[0].remix_count, 0); + assert_eq!(items[0].like_count, 0); + } } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/server-rs/crates/spacetime-client/src/match3d.rs b/server-rs/crates/spacetime-client/src/match3d.rs new file mode 100644 index 00000000..32db7478 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/match3d.rs @@ -0,0 +1,466 @@ +use super::*; +use crate::mapper::*; + +impl SpacetimeClient { + pub async fn create_match3d_agent_session( + &self, + input: Match3DAgentSessionCreateRecordInput, + ) -> Result { + let procedure_input = Match3DAgentSessionCreateInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + seed_text: input.seed_text, + welcome_message_id: input.welcome_message_id, + welcome_message_text: input.welcome_message_text, + config_json: input.config_json, + created_at_micros: input.created_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().create_match_3_d_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn get_match3d_agent_session( + &self, + session_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = Match3DAgentSessionGetInput { + session_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_match_3_d_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn submit_match3d_agent_message( + &self, + input: Match3DAgentMessageSubmitRecordInput, + ) -> Result { + let procedure_input = Match3DAgentMessageSubmitInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + user_message_id: input.user_message_id, + user_message_text: input.user_message_text, + submitted_at_micros: input.submitted_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().submit_match_3_d_agent_message_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn finalize_match3d_agent_message( + &self, + input: Match3DAgentMessageFinalizeRecordInput, + ) -> Result { + let procedure_input = Match3DAgentMessageFinalizeInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + assistant_message_id: input.assistant_message_id, + assistant_reply_text: input.assistant_reply_text, + config_json: input.config_json, + progress_percent: input.progress_percent, + stage: input.stage, + updated_at_micros: input.updated_at_micros, + error_message: input.error_message, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .finalize_match_3_d_agent_message_turn_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn compile_match3d_draft( + &self, + input: Match3DCompileDraftRecordInput, + ) -> Result { + let procedure_input = Match3DDraftCompileInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + author_display_name: input.author_display_name, + game_name: input.game_name, + summary_text: input.summary_text, + tags_json: input.tags_json, + cover_image_src: input.cover_image_src, + cover_asset_id: input.cover_asset_id, + compiled_at_micros: input.compiled_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().compile_match_3_d_draft_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn update_match3d_work( + &self, + input: Match3DWorkUpdateRecordInput, + ) -> Result { + let procedure_input = Match3DWorkUpdateInput { + profile_id: input.profile_id, + owner_user_id: input.owner_user_id, + game_name: input.game_name, + theme_text: input.theme_text, + summary_text: input.summary_text, + tags_json: input.tags_json, + cover_image_src: input.cover_image_src, + cover_asset_id: input.cover_asset_id, + clear_count: input.clear_count, + difficulty: input.difficulty, + updated_at_micros: input.updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().update_match_3_d_work_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn publish_match3d_work( + &self, + profile_id: String, + owner_user_id: String, + published_at_micros: i64, + ) -> Result { + let procedure_input = Match3DWorkPublishInput { + profile_id, + owner_user_id, + published_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().publish_match_3_d_work_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn list_match3d_works( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + self.list_match3d_works_with_input(Match3DWorksListInput { + owner_user_id, + published_only: false, + }) + .await + } + + pub async fn list_match3d_gallery( + &self, + ) -> Result, SpacetimeClientError> { + self.list_match3d_works_with_input(Match3DWorksListInput { + // 中文注释:公开广场读取只依赖 published_only,owner_user_id 保持非空便于兼容校验。 + owner_user_id: "match3d-public-gallery".to_string(), + published_only: true, + }) + .await + } + + async fn list_match3d_works_with_input( + &self, + procedure_input: Match3DWorksListInput, + ) -> Result, SpacetimeClientError> { + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .list_match_3_d_works_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_works_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn get_match3d_work_detail( + &self, + profile_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = Match3DWorkGetInput { + profile_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_match_3_d_work_detail_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn delete_match3d_work( + &self, + profile_id: String, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = Match3DWorkDeleteInput { + profile_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().delete_match_3_d_work_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_works_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn start_match3d_run( + &self, + input: Match3DRunStartRecordInput, + ) -> Result { + let owner_user_id = input.owner_user_id.clone(); + let procedure_input = Match3DRunStartInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + started_at_ms: input.started_at_ms, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .start_match_3_d_run_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_run_procedure_result) + .map(|mut run| { + run.owner_user_id = owner_user_id; + run + }); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn get_match3d_run( + &self, + run_id: String, + owner_user_id: String, + ) -> Result { + let procedure_owner_user_id = owner_user_id.clone(); + let procedure_input = Match3DRunGetInput { + run_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_match_3_d_run_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_run_procedure_result) + .map(|mut run| { + run.owner_user_id = procedure_owner_user_id; + run + }); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn click_match3d_item( + &self, + input: Match3DRunClickRecordInput, + ) -> Result { + let owner_user_id = input.owner_user_id.clone(); + let client_event_id = input.client_event_id.clone(); + let procedure_input = Match3DRunClickInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + item_instance_id: input.item_instance_id, + client_snapshot_version: input.client_snapshot_version, + client_event_id: input.client_event_id, + clicked_at_ms: input.clicked_at_ms, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .click_match_3_d_item_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_click_item_procedure_result) + .map(|mut confirmation| { + confirmation.run.owner_user_id = owner_user_id; + if confirmation.accepted { + confirmation.run.last_confirmed_action_id = Some(client_event_id); + } + confirmation + }); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn stop_match3d_run( + &self, + input: Match3DRunStopRecordInput, + ) -> Result { + let owner_user_id = input.owner_user_id.clone(); + let procedure_input = Match3DRunStopInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + stopped_at_ms: input.stopped_at_ms, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .stop_match_3_d_run_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_run_procedure_result) + .map(|mut run| { + run.owner_user_id = owner_user_id; + run + }); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn restart_match3d_run( + &self, + input: Match3DRunRestartRecordInput, + ) -> Result { + let owner_user_id = input.owner_user_id.clone(); + let procedure_input = Match3DRunRestartInput { + source_run_id: input.source_run_id, + next_run_id: input.next_run_id, + owner_user_id: input.owner_user_id, + restarted_at_ms: input.restarted_at_ms, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().restart_match_3_d_run_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_run_procedure_result) + .map(|mut run| { + run.owner_user_id = owner_user_id; + run + }); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn finish_match3d_time_up( + &self, + input: Match3DRunTimeUpRecordInput, + ) -> Result { + let owner_user_id = input.owner_user_id.clone(); + let procedure_input = Match3DRunTimeUpInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + finished_at_ms: input.finished_at_ms, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().finish_match_3_d_time_up_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_run_procedure_result) + .map(|mut run| { + run.owner_user_id = owner_user_id; + run + }); + send_once(&sender, mapped); + }, + ); + }) + .await + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs new file mode 100644 index 00000000..2411092d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult; +use super::runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInviteCodeAdminUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminUpsertProfileInviteCodeArgs { + pub input: RuntimeProfileInviteCodeAdminUpsertInput, +} + +impl __sdk::InModule for AdminUpsertProfileInviteCodeArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `admin_upsert_profile_invite_code`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait admin_upsert_profile_invite_code { + fn admin_upsert_profile_invite_code(&self, input: RuntimeProfileInviteCodeAdminUpsertInput) { + self.admin_upsert_profile_invite_code_then(input, |_, _| {}); + } + + fn admin_upsert_profile_invite_code_then( + &self, + input: RuntimeProfileInviteCodeAdminUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_upsert_profile_invite_code for super::RemoteProcedures { + fn admin_upsert_profile_invite_code_then( + &self, + input: RuntimeProfileInviteCodeAdminUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminProcedureResult>( + "admin_upsert_profile_invite_code", + AdminUpsertProfileInviteCodeArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs index a760fea0..572889b8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs @@ -20,9 +20,12 @@ pub struct BigFishCreationSession { pub asset_coverage_json: String, pub last_assistant_reply: Option, pub publish_ready: bool, - pub play_count: u32, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub published_at: Option<__sdk::Timestamp>, } impl __sdk::InModule for BigFishCreationSession { @@ -44,9 +47,12 @@ pub struct BigFishCreationSessionCols { pub asset_coverage_json: __sdk::__query_builder::Col, pub last_assistant_reply: __sdk::__query_builder::Col>, pub publish_ready: __sdk::__query_builder::Col, - pub play_count: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub remix_count: __sdk::__query_builder::Col, + pub like_count: __sdk::__query_builder::Col, + pub published_at: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for BigFishCreationSession { @@ -70,9 +76,12 @@ impl __sdk::__query_builder::HasCols for BigFishCreationSession { "last_assistant_reply", ), publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), - play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"), + like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), + published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_like_record_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_like_record_input_type.rs new file mode 100644 index 00000000..b48273c9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_like_record_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BigFishWorkLikeRecordInput { + pub session_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + +impl __sdk::InModule for BigFishWorkLikeRecordInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_remix_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_remix_input_type.rs new file mode 100644 index 00000000..dc05718c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_remix_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BigFishWorkRemixInput { + pub source_session_id: String, + pub target_session_id: String, + pub target_owner_user_id: String, + pub welcome_message_id: String, + pub remixed_at_micros: i64, +} + +impl __sdk::InModule for BigFishWorkRemixInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/claim_puzzle_work_point_incentive_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/claim_puzzle_work_point_incentive_procedure.rs new file mode 100644 index 00000000..1d787222 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/claim_puzzle_work_point_incentive_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_work_point_incentive_claim_input_type::PuzzleWorkPointIncentiveClaimInput; +use super::puzzle_work_procedure_result_type::PuzzleWorkProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ClaimPuzzleWorkPointIncentiveArgs { + pub input: PuzzleWorkPointIncentiveClaimInput, +} + +impl __sdk::InModule for ClaimPuzzleWorkPointIncentiveArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `claim_puzzle_work_point_incentive`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait claim_puzzle_work_point_incentive { + fn claim_puzzle_work_point_incentive(&self, input: PuzzleWorkPointIncentiveClaimInput) { + self.claim_puzzle_work_point_incentive_then(input, |_, _| {}); + } + + fn claim_puzzle_work_point_incentive_then( + &self, + input: PuzzleWorkPointIncentiveClaimInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl claim_puzzle_work_point_incentive for super::RemoteProcedures { + fn claim_puzzle_work_point_incentive_then( + &self, + input: PuzzleWorkPointIncentiveClaimInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleWorkProcedureResult>( + "claim_puzzle_work_point_incentive", + ClaimPuzzleWorkPointIncentiveArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/clear_database_migration_import_chunks_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/clear_database_migration_import_chunks_procedure.rs new file mode 100644 index 00000000..d05fcdf2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/clear_database_migration_import_chunks_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::database_migration_import_chunks_clear_input_type::DatabaseMigrationImportChunksClearInput; +use super::database_migration_procedure_result_type::DatabaseMigrationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ClearDatabaseMigrationImportChunksArgs { + pub input: DatabaseMigrationImportChunksClearInput, +} + +impl __sdk::InModule for ClearDatabaseMigrationImportChunksArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `clear_database_migration_import_chunks`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait clear_database_migration_import_chunks { + fn clear_database_migration_import_chunks( + &self, + input: DatabaseMigrationImportChunksClearInput, + ) { + self.clear_database_migration_import_chunks_then(input, |_, _| {}); + } + + fn clear_database_migration_import_chunks_then( + &self, + input: DatabaseMigrationImportChunksClearInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl clear_database_migration_import_chunks for super::RemoteProcedures { + fn clear_database_migration_import_chunks_then( + &self, + input: DatabaseMigrationImportChunksClearInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( + "clear_database_migration_import_chunks", + ClearDatabaseMigrationImportChunksArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/click_match_3_d_item_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/click_match_3_d_item_procedure.rs new file mode 100644 index 00000000..278845ba --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/click_match_3_d_item_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_click_item_procedure_result_type::Match3DClickItemProcedureResult; +use super::match_3_d_run_click_input_type::Match3DRunClickInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ClickMatch3DItemArgs { + pub input: Match3DRunClickInput, +} + +impl __sdk::InModule for ClickMatch3DItemArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `click_match_3_d_item`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait click_match_3_d_item { + fn click_match_3_d_item(&self, input: Match3DRunClickInput) { + self.click_match_3_d_item_then(input, |_, _| {}); + } + + fn click_match_3_d_item_then( + &self, + input: Match3DRunClickInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl click_match_3_d_item for super::RemoteProcedures { + fn click_match_3_d_item_then( + &self, + input: Match3DRunClickInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DClickItemProcedureResult>( + "click_match_3_d_item", + ClickMatch3DItemArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/compile_match_3_d_draft_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/compile_match_3_d_draft_procedure.rs new file mode 100644 index 00000000..1a7a97ed --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/compile_match_3_d_draft_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_agent_session_procedure_result_type::Match3DAgentSessionProcedureResult; +use super::match_3_d_draft_compile_input_type::Match3DDraftCompileInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CompileMatch3DDraftArgs { + pub input: Match3DDraftCompileInput, +} + +impl __sdk::InModule for CompileMatch3DDraftArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `compile_match_3_d_draft`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait compile_match_3_d_draft { + fn compile_match_3_d_draft(&self, input: Match3DDraftCompileInput) { + self.compile_match_3_d_draft_then(input, |_, _| {}); + } + + fn compile_match_3_d_draft_then( + &self, + input: Match3DDraftCompileInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl compile_match_3_d_draft for super::RemoteProcedures { + fn compile_match_3_d_draft_then( + &self, + input: Match3DDraftCompileInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DAgentSessionProcedureResult>( + "compile_match_3_d_draft", + CompileMatch3DDraftArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_match_3_d_agent_session_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_match_3_d_agent_session_procedure.rs new file mode 100644 index 00000000..717ef728 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_match_3_d_agent_session_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_agent_session_create_input_type::Match3DAgentSessionCreateInput; +use super::match_3_d_agent_session_procedure_result_type::Match3DAgentSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreateMatch3DAgentSessionArgs { + pub input: Match3DAgentSessionCreateInput, +} + +impl __sdk::InModule for CreateMatch3DAgentSessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_match_3_d_agent_session`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_match_3_d_agent_session { + fn create_match_3_d_agent_session(&self, input: Match3DAgentSessionCreateInput) { + self.create_match_3_d_agent_session_then(input, |_, _| {}); + } + + fn create_match_3_d_agent_session_then( + &self, + input: Match3DAgentSessionCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_match_3_d_agent_session for super::RemoteProcedures { + fn create_match_3_d_agent_session_then( + &self, + input: Match3DAgentSessionCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DAgentSessionProcedureResult>( + "create_match_3_d_agent_session", + CreateMatch3DAgentSessionArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs index ac3cf555..9a3c1338 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_snapshot_type.rs @@ -21,6 +21,10 @@ pub struct CustomWorldGalleryEntrySnapshot { pub theme_mode: CustomWorldThemeMode, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub recent_play_count_7_d: u32, pub published_at_micros: i64, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs index a1fc0481..971fd3b2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_gallery_entry_type.rs @@ -21,6 +21,9 @@ pub struct CustomWorldGalleryEntry { pub theme_mode: CustomWorldThemeMode, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, pub published_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, } @@ -45,6 +48,9 @@ pub struct CustomWorldGalleryEntryCols { pub theme_mode: __sdk::__query_builder::Col, pub playable_npc_count: __sdk::__query_builder::Col, pub landmark_count: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub remix_count: __sdk::__query_builder::Col, + pub like_count: __sdk::__query_builder::Col, pub published_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, } @@ -71,6 +77,9 @@ impl __sdk::__query_builder::HasCols for CustomWorldGalleryEntry { theme_mode: __sdk::__query_builder::Col::new(table_name, "theme_mode"), playable_npc_count: __sdk::__query_builder::Col::new(table_name, "playable_npc_count"), landmark_count: __sdk::__query_builder::Col::new(table_name, "landmark_count"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"), + like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_like_record_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_like_record_input_type.rs new file mode 100644 index 00000000..feb884fc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_like_record_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldProfileLikeRecordInput { + pub owner_user_id: String, + pub profile_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldProfileLikeRecordInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_play_record_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_play_record_input_type.rs new file mode 100644 index 00000000..6c71b8a3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_play_record_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldProfilePlayRecordInput { + pub owner_user_id: String, + pub profile_id: String, + pub played_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldProfilePlayRecordInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_remix_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_remix_input_type.rs new file mode 100644 index 00000000..f995a468 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_remix_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldProfileRemixInput { + pub source_owner_user_id: String, + pub source_profile_id: String, + pub target_owner_user_id: String, + pub target_profile_id: String, + pub author_display_name: String, + pub remixed_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldProfileRemixInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs index 3b9f8b5a..f4a433b3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_snapshot_type.rs @@ -24,6 +24,9 @@ pub struct CustomWorldProfileSnapshot { pub profile_payload_json: String, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, pub author_display_name: String, pub published_at_micros: Option, pub deleted_at_micros: Option, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs index 8923286f..4ad1e730 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_type.rs @@ -24,6 +24,9 @@ pub struct CustomWorldProfile { pub profile_payload_json: String, pub playable_npc_count: u32, pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, pub author_display_name: String, pub published_at: Option<__sdk::Timestamp>, pub deleted_at: Option<__sdk::Timestamp>, @@ -54,6 +57,9 @@ pub struct CustomWorldProfileCols { pub profile_payload_json: __sdk::__query_builder::Col, pub playable_npc_count: __sdk::__query_builder::Col, pub landmark_count: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub remix_count: __sdk::__query_builder::Col, + pub like_count: __sdk::__query_builder::Col, pub author_display_name: __sdk::__query_builder::Col, pub published_at: __sdk::__query_builder::Col>, pub deleted_at: __sdk::__query_builder::Col>, @@ -88,6 +94,9 @@ impl __sdk::__query_builder::HasCols for CustomWorldProfile { ), playable_npc_count: __sdk::__query_builder::Col::new(table_name, "playable_npc_count"), landmark_count: __sdk::__query_builder::Col::new(table_name, "landmark_count"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"), + like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), author_display_name: __sdk::__query_builder::Col::new( table_name, "author_display_name", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunk_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunk_input_type.rs new file mode 100644 index 00000000..76112c40 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunk_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct DatabaseMigrationImportChunkInput { + pub upload_id: String, + pub chunk_index: u32, + pub chunk_count: u32, + pub chunk: String, +} + +impl __sdk::InModule for DatabaseMigrationImportChunkInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunk_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunk_type.rs new file mode 100644 index 00000000..dfa3ad49 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunk_type.rs @@ -0,0 +1,70 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct DatabaseMigrationImportChunk { + pub chunk_key: String, + pub upload_id: String, + pub chunk_index: u32, + pub chunk_count: u32, + pub operator_identity: __sdk::Identity, + pub created_at: __sdk::Timestamp, + pub chunk: String, +} + +impl __sdk::InModule for DatabaseMigrationImportChunk { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `DatabaseMigrationImportChunk`. +/// +/// Provides typed access to columns for query building. +pub struct DatabaseMigrationImportChunkCols { + pub chunk_key: __sdk::__query_builder::Col, + pub upload_id: __sdk::__query_builder::Col, + pub chunk_index: __sdk::__query_builder::Col, + pub chunk_count: __sdk::__query_builder::Col, + pub operator_identity: + __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub chunk: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for DatabaseMigrationImportChunk { + type Cols = DatabaseMigrationImportChunkCols; + fn cols(table_name: &'static str) -> Self::Cols { + DatabaseMigrationImportChunkCols { + chunk_key: __sdk::__query_builder::Col::new(table_name, "chunk_key"), + upload_id: __sdk::__query_builder::Col::new(table_name, "upload_id"), + chunk_index: __sdk::__query_builder::Col::new(table_name, "chunk_index"), + chunk_count: __sdk::__query_builder::Col::new(table_name, "chunk_count"), + operator_identity: __sdk::__query_builder::Col::new(table_name, "operator_identity"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + chunk: __sdk::__query_builder::Col::new(table_name, "chunk"), + } + } +} + +/// Indexed column accessor struct for the table `DatabaseMigrationImportChunk`. +/// +/// Provides typed access to indexed columns for query building. +pub struct DatabaseMigrationImportChunkIxCols { + pub chunk_key: __sdk::__query_builder::IxCol, + pub upload_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for DatabaseMigrationImportChunk { + type IxCols = DatabaseMigrationImportChunkIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + DatabaseMigrationImportChunkIxCols { + chunk_key: __sdk::__query_builder::IxCol::new(table_name, "chunk_key"), + upload_id: __sdk::__query_builder::IxCol::new(table_name, "upload_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for DatabaseMigrationImportChunk {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunks_clear_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunks_clear_input_type.rs new file mode 100644 index 00000000..8026921c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunks_clear_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct DatabaseMigrationImportChunksClearInput { + pub upload_id: String, +} + +impl __sdk::InModule for DatabaseMigrationImportChunksClearInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunks_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunks_input_type.rs new file mode 100644 index 00000000..3bbdf903 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunks_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct DatabaseMigrationImportChunksInput { + pub upload_id: String, + pub include_tables: Vec, + pub replace_existing: bool, + pub dry_run: bool, +} + +impl __sdk::InModule for DatabaseMigrationImportChunksInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_procedure_result_type.rs index a3869c5d..3efd7d4c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_procedure_result_type.rs @@ -5,6 +5,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::database_migration_table_stat_type::DatabaseMigrationTableStat; +use super::database_migration_warning_type::DatabaseMigrationWarning; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] @@ -13,6 +14,7 @@ pub struct DatabaseMigrationProcedureResult { pub schema_version: u32, pub migration_json: Option, pub table_stats: Vec, + pub warnings: Vec, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_warning_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_warning_type.rs new file mode 100644 index 00000000..7569ae84 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_warning_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct DatabaseMigrationWarning { + pub table_name: String, + pub warning_kind: String, + pub message: String, +} + +impl __sdk::InModule for DatabaseMigrationWarning { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/delete_match_3_d_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/delete_match_3_d_work_procedure.rs new file mode 100644 index 00000000..c87cd16a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/delete_match_3_d_work_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_work_delete_input_type::Match3DWorkDeleteInput; +use super::match_3_d_works_procedure_result_type::Match3DWorksProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct DeleteMatch3DWorkArgs { + pub input: Match3DWorkDeleteInput, +} + +impl __sdk::InModule for DeleteMatch3DWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `delete_match_3_d_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait delete_match_3_d_work { + fn delete_match_3_d_work(&self, input: Match3DWorkDeleteInput) { + self.delete_match_3_d_work_then(input, |_, _| {}); + } + + fn delete_match_3_d_work_then( + &self, + input: Match3DWorkDeleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl delete_match_3_d_work for super::RemoteProcedures { + fn delete_match_3_d_work_then( + &self, + input: Match3DWorkDeleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DWorksProcedureResult>( + "delete_match_3_d_work", + DeleteMatch3DWorkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/finalize_match_3_d_agent_message_turn_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/finalize_match_3_d_agent_message_turn_procedure.rs new file mode 100644 index 00000000..ea0ec225 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/finalize_match_3_d_agent_message_turn_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput; +use super::match_3_d_agent_session_procedure_result_type::Match3DAgentSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct FinalizeMatch3DAgentMessageTurnArgs { + pub input: Match3DAgentMessageFinalizeInput, +} + +impl __sdk::InModule for FinalizeMatch3DAgentMessageTurnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `finalize_match_3_d_agent_message_turn`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait finalize_match_3_d_agent_message_turn { + fn finalize_match_3_d_agent_message_turn(&self, input: Match3DAgentMessageFinalizeInput) { + self.finalize_match_3_d_agent_message_turn_then(input, |_, _| {}); + } + + fn finalize_match_3_d_agent_message_turn_then( + &self, + input: Match3DAgentMessageFinalizeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl finalize_match_3_d_agent_message_turn for super::RemoteProcedures { + fn finalize_match_3_d_agent_message_turn_then( + &self, + input: Match3DAgentMessageFinalizeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DAgentSessionProcedureResult>( + "finalize_match_3_d_agent_message_turn", + FinalizeMatch3DAgentMessageTurnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/finish_match_3_d_time_up_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/finish_match_3_d_time_up_procedure.rs new file mode 100644 index 00000000..bd849631 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/finish_match_3_d_time_up_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_run_procedure_result_type::Match3DRunProcedureResult; +use super::match_3_d_run_time_up_input_type::Match3DRunTimeUpInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct FinishMatch3DTimeUpArgs { + pub input: Match3DRunTimeUpInput, +} + +impl __sdk::InModule for FinishMatch3DTimeUpArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `finish_match_3_d_time_up`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait finish_match_3_d_time_up { + fn finish_match_3_d_time_up(&self, input: Match3DRunTimeUpInput) { + self.finish_match_3_d_time_up_then(input, |_, _| {}); + } + + fn finish_match_3_d_time_up_then( + &self, + input: Match3DRunTimeUpInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl finish_match_3_d_time_up for super::RemoteProcedures { + fn finish_match_3_d_time_up_then( + &self, + input: Match3DRunTimeUpInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DRunProcedureResult>( + "finish_match_3_d_time_up", + FinishMatch3DTimeUpArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_match_3_d_agent_session_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_match_3_d_agent_session_procedure.rs new file mode 100644 index 00000000..574012d9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_match_3_d_agent_session_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_agent_session_get_input_type::Match3DAgentSessionGetInput; +use super::match_3_d_agent_session_procedure_result_type::Match3DAgentSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetMatch3DAgentSessionArgs { + pub input: Match3DAgentSessionGetInput, +} + +impl __sdk::InModule for GetMatch3DAgentSessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_match_3_d_agent_session`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_match_3_d_agent_session { + fn get_match_3_d_agent_session(&self, input: Match3DAgentSessionGetInput) { + self.get_match_3_d_agent_session_then(input, |_, _| {}); + } + + fn get_match_3_d_agent_session_then( + &self, + input: Match3DAgentSessionGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_match_3_d_agent_session for super::RemoteProcedures { + fn get_match_3_d_agent_session_then( + &self, + input: Match3DAgentSessionGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DAgentSessionProcedureResult>( + "get_match_3_d_agent_session", + GetMatch3DAgentSessionArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_match_3_d_run_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_match_3_d_run_procedure.rs new file mode 100644 index 00000000..0a472230 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_match_3_d_run_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_run_get_input_type::Match3DRunGetInput; +use super::match_3_d_run_procedure_result_type::Match3DRunProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetMatch3DRunArgs { + pub input: Match3DRunGetInput, +} + +impl __sdk::InModule for GetMatch3DRunArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_match_3_d_run`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_match_3_d_run { + fn get_match_3_d_run(&self, input: Match3DRunGetInput) { + self.get_match_3_d_run_then(input, |_, _| {}); + } + + fn get_match_3_d_run_then( + &self, + input: Match3DRunGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_match_3_d_run for super::RemoteProcedures { + fn get_match_3_d_run_then( + &self, + input: Match3DRunGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DRunProcedureResult>( + "get_match_3_d_run", + GetMatch3DRunArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_match_3_d_work_detail_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_match_3_d_work_detail_procedure.rs new file mode 100644 index 00000000..0ea9f495 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_match_3_d_work_detail_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_work_get_input_type::Match3DWorkGetInput; +use super::match_3_d_work_procedure_result_type::Match3DWorkProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetMatch3DWorkDetailArgs { + pub input: Match3DWorkGetInput, +} + +impl __sdk::InModule for GetMatch3DWorkDetailArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_match_3_d_work_detail`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_match_3_d_work_detail { + fn get_match_3_d_work_detail(&self, input: Match3DWorkGetInput) { + self.get_match_3_d_work_detail_then(input, |_, _| {}); + } + + fn get_match_3_d_work_detail_then( + &self, + input: Match3DWorkGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_match_3_d_work_detail for super::RemoteProcedures { + fn get_match_3_d_work_detail_then( + &self, + input: Match3DWorkGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DWorkProcedureResult>( + "get_match_3_d_work_detail", + GetMatch3DWorkDetailArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/grant_new_user_registration_wallet_reward_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/grant_new_user_registration_wallet_reward_procedure.rs new file mode 100644 index 00000000..71e48151 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/grant_new_user_registration_wallet_reward_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput; +use super::runtime_profile_wallet_adjustment_procedure_result_type::RuntimeProfileWalletAdjustmentProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GrantNewUserRegistrationWalletRewardArgs { + pub input: RuntimeProfileDashboardGetInput, +} + +impl __sdk::InModule for GrantNewUserRegistrationWalletRewardArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `grant_new_user_registration_wallet_reward`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait grant_new_user_registration_wallet_reward { + fn grant_new_user_registration_wallet_reward(&self, input: RuntimeProfileDashboardGetInput) { + self.grant_new_user_registration_wallet_reward_then(input, |_, _| {}); + } + + fn grant_new_user_registration_wallet_reward_then( + &self, + input: RuntimeProfileDashboardGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl grant_new_user_registration_wallet_reward for super::RemoteProcedures { + fn grant_new_user_registration_wallet_reward_then( + &self, + input: RuntimeProfileDashboardGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileWalletAdjustmentProcedureResult>( + "grant_new_user_registration_wallet_reward", + GrantNewUserRegistrationWalletRewardArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_chunks_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_chunks_procedure.rs new file mode 100644 index 00000000..080dda54 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_chunks_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::database_migration_import_chunks_input_type::DatabaseMigrationImportChunksInput; +use super::database_migration_procedure_result_type::DatabaseMigrationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ImportDatabaseMigrationFromChunksArgs { + pub input: DatabaseMigrationImportChunksInput, +} + +impl __sdk::InModule for ImportDatabaseMigrationFromChunksArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `import_database_migration_from_chunks`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait import_database_migration_from_chunks { + fn import_database_migration_from_chunks(&self, input: DatabaseMigrationImportChunksInput) { + self.import_database_migration_from_chunks_then(input, |_, _| {}); + } + + fn import_database_migration_from_chunks_then( + &self, + input: DatabaseMigrationImportChunksInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl import_database_migration_from_chunks for super::RemoteProcedures { + fn import_database_migration_from_chunks_then( + &self, + input: DatabaseMigrationImportChunksInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( + "import_database_migration_from_chunks", + ImportDatabaseMigrationFromChunksArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_chunks_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_chunks_procedure.rs new file mode 100644 index 00000000..bbe49357 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_chunks_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::database_migration_import_chunks_input_type::DatabaseMigrationImportChunksInput; +use super::database_migration_procedure_result_type::DatabaseMigrationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ImportDatabaseMigrationIncrementalFromChunksArgs { + pub input: DatabaseMigrationImportChunksInput, +} + +impl __sdk::InModule for ImportDatabaseMigrationIncrementalFromChunksArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `import_database_migration_incremental_from_chunks`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait import_database_migration_incremental_from_chunks { + fn import_database_migration_incremental_from_chunks( + &self, + input: DatabaseMigrationImportChunksInput, + ) { + self.import_database_migration_incremental_from_chunks_then(input, |_, _| {}); + } + + fn import_database_migration_incremental_from_chunks_then( + &self, + input: DatabaseMigrationImportChunksInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl import_database_migration_incremental_from_chunks for super::RemoteProcedures { + fn import_database_migration_incremental_from_chunks_then( + &self, + input: DatabaseMigrationImportChunksInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( + "import_database_migration_incremental_from_chunks", + ImportDatabaseMigrationIncrementalFromChunksArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_match_3_d_works_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_match_3_d_works_procedure.rs new file mode 100644 index 00000000..c593e848 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_match_3_d_works_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_works_list_input_type::Match3DWorksListInput; +use super::match_3_d_works_procedure_result_type::Match3DWorksProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListMatch3DWorksArgs { + pub input: Match3DWorksListInput, +} + +impl __sdk::InModule for ListMatch3DWorksArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_match_3_d_works`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_match_3_d_works { + fn list_match_3_d_works(&self, input: Match3DWorksListInput) { + self.list_match_3_d_works_then(input, |_, _| {}); + } + + fn list_match_3_d_works_then( + &self, + input: Match3DWorksListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_match_3_d_works for super::RemoteProcedures { + fn list_match_3_d_works_then( + &self, + input: Match3DWorksListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DWorksProcedureResult>( + "list_match_3_d_works", + ListMatch3DWorksArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_message_finalize_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_message_finalize_input_type.rs new file mode 100644 index 00000000..0680d93b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_message_finalize_input_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DAgentMessageFinalizeInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub config_json: Option, + pub progress_percent: u32, + pub stage: String, + pub updated_at_micros: i64, + pub error_message: Option, +} + +impl __sdk::InModule for Match3DAgentMessageFinalizeInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_message_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_message_row_type.rs new file mode 100644 index 00000000..691b9f24 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_message_row_type.rs @@ -0,0 +1,66 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DAgentMessageRow { + pub message_id: String, + pub session_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for Match3DAgentMessageRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `Match3DAgentMessageRow`. +/// +/// Provides typed access to columns for query building. +pub struct Match3DAgentMessageRowCols { + pub message_id: __sdk::__query_builder::Col, + pub session_id: __sdk::__query_builder::Col, + pub role: __sdk::__query_builder::Col, + pub kind: __sdk::__query_builder::Col, + pub text: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for Match3DAgentMessageRow { + type Cols = Match3DAgentMessageRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + Match3DAgentMessageRowCols { + message_id: __sdk::__query_builder::Col::new(table_name, "message_id"), + session_id: __sdk::__query_builder::Col::new(table_name, "session_id"), + role: __sdk::__query_builder::Col::new(table_name, "role"), + kind: __sdk::__query_builder::Col::new(table_name, "kind"), + text: __sdk::__query_builder::Col::new(table_name, "text"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `Match3DAgentMessageRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct Match3DAgentMessageRowIxCols { + pub message_id: __sdk::__query_builder::IxCol, + pub session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for Match3DAgentMessageRow { + type IxCols = Match3DAgentMessageRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + Match3DAgentMessageRowIxCols { + message_id: __sdk::__query_builder::IxCol::new(table_name, "message_id"), + session_id: __sdk::__query_builder::IxCol::new(table_name, "session_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for Match3DAgentMessageRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_message_submit_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_message_submit_input_type.rs new file mode 100644 index 00000000..cfadc207 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_message_submit_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DAgentMessageSubmitInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub submitted_at_micros: i64, +} + +impl __sdk::InModule for Match3DAgentMessageSubmitInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_create_input_type.rs new file mode 100644 index 00000000..fec57547 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_create_input_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DAgentSessionCreateInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub config_json: Option, + pub created_at_micros: i64, +} + +impl __sdk::InModule for Match3DAgentSessionCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_get_input_type.rs new file mode 100644 index 00000000..4a19d565 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DAgentSessionGetInput { + pub session_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for Match3DAgentSessionGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_procedure_result_type.rs new file mode 100644 index 00000000..45f54f93 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_procedure_result_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DAgentSessionProcedureResult { + pub ok: bool, + pub session_json: Option, + pub error_message: Option, +} + +impl __sdk::InModule for Match3DAgentSessionProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_row_type.rs new file mode 100644 index 00000000..49c07da7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_row_type.rs @@ -0,0 +1,90 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DAgentSessionRow { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub config_json: String, + pub draft_json: String, + pub last_assistant_reply: String, + pub published_profile_id: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for Match3DAgentSessionRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `Match3DAgentSessionRow`. +/// +/// Provides typed access to columns for query building. +pub struct Match3DAgentSessionRowCols { + pub session_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub seed_text: __sdk::__query_builder::Col, + pub current_turn: __sdk::__query_builder::Col, + pub progress_percent: __sdk::__query_builder::Col, + pub stage: __sdk::__query_builder::Col, + pub config_json: __sdk::__query_builder::Col, + pub draft_json: __sdk::__query_builder::Col, + pub last_assistant_reply: __sdk::__query_builder::Col, + pub published_profile_id: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for Match3DAgentSessionRow { + type Cols = Match3DAgentSessionRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + Match3DAgentSessionRowCols { + session_id: __sdk::__query_builder::Col::new(table_name, "session_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + seed_text: __sdk::__query_builder::Col::new(table_name, "seed_text"), + current_turn: __sdk::__query_builder::Col::new(table_name, "current_turn"), + progress_percent: __sdk::__query_builder::Col::new(table_name, "progress_percent"), + stage: __sdk::__query_builder::Col::new(table_name, "stage"), + config_json: __sdk::__query_builder::Col::new(table_name, "config_json"), + draft_json: __sdk::__query_builder::Col::new(table_name, "draft_json"), + last_assistant_reply: __sdk::__query_builder::Col::new( + table_name, + "last_assistant_reply", + ), + published_profile_id: __sdk::__query_builder::Col::new( + table_name, + "published_profile_id", + ), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `Match3DAgentSessionRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct Match3DAgentSessionRowIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for Match3DAgentSessionRow { + type IxCols = Match3DAgentSessionRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + Match3DAgentSessionRowIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + session_id: __sdk::__query_builder::IxCol::new(table_name, "session_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for Match3DAgentSessionRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_click_item_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_click_item_procedure_result_type.rs new file mode 100644 index 00000000..80f32a59 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_click_item_procedure_result_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DClickItemProcedureResult { + pub ok: bool, + pub status: String, + pub run_json: Option, + pub accepted_item_instance_id: Option, + pub cleared_item_instance_ids: Vec, + pub failure_reason: Option, + pub error_message: Option, +} + +impl __sdk::InModule for Match3DClickItemProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_draft_compile_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_draft_compile_input_type.rs new file mode 100644 index 00000000..e8b5776d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_draft_compile_input_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DDraftCompileInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub author_display_name: String, + pub game_name: Option, + pub summary_text: Option, + pub tags_json: Option, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub compiled_at_micros: i64, +} + +impl __sdk::InModule for Match3DDraftCompileInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_click_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_click_input_type.rs new file mode 100644 index 00000000..97236799 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_click_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DRunClickInput { + pub run_id: String, + pub owner_user_id: String, + pub item_instance_id: String, + pub client_snapshot_version: u32, + pub client_event_id: String, + pub clicked_at_ms: i64, +} + +impl __sdk::InModule for Match3DRunClickInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_get_input_type.rs new file mode 100644 index 00000000..4f10213b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DRunGetInput { + pub run_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for Match3DRunGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_procedure_result_type.rs new file mode 100644 index 00000000..f3c4ceec --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_procedure_result_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DRunProcedureResult { + pub ok: bool, + pub run_json: Option, + pub error_message: Option, +} + +impl __sdk::InModule for Match3DRunProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_restart_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_restart_input_type.rs new file mode 100644 index 00000000..b75f4e11 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_restart_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DRunRestartInput { + pub source_run_id: String, + pub next_run_id: String, + pub owner_user_id: String, + pub restarted_at_ms: i64, +} + +impl __sdk::InModule for Match3DRunRestartInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_start_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_start_input_type.rs new file mode 100644 index 00000000..94653050 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_start_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DRunStartInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub started_at_ms: i64, +} + +impl __sdk::InModule for Match3DRunStartInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_stop_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_stop_input_type.rs new file mode 100644 index 00000000..f905d7ca --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_stop_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DRunStopInput { + pub run_id: String, + pub owner_user_id: String, + pub stopped_at_ms: i64, +} + +impl __sdk::InModule for Match3DRunStopInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_time_up_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_time_up_input_type.rs new file mode 100644 index 00000000..77fc4ca2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_time_up_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DRunTimeUpInput { + pub run_id: String, + pub owner_user_id: String, + pub finished_at_ms: i64, +} + +impl __sdk::InModule for Match3DRunTimeUpInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_runtime_run_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_runtime_run_row_type.rs new file mode 100644 index 00000000..66e8d4aa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_runtime_run_row_type.rs @@ -0,0 +1,98 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DRuntimeRunRow { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub status: String, + pub snapshot_version: u32, + pub started_at_ms: i64, + pub duration_limit_ms: i64, + pub finished_at_ms: i64, + pub elapsed_ms: i64, + pub clear_count: u32, + pub total_item_count: u32, + pub cleared_item_count: u32, + pub failure_reason: String, + pub snapshot_json: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for Match3DRuntimeRunRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `Match3DRuntimeRunRow`. +/// +/// Provides typed access to columns for query building. +pub struct Match3DRuntimeRunRowCols { + pub run_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub snapshot_version: __sdk::__query_builder::Col, + pub started_at_ms: __sdk::__query_builder::Col, + pub duration_limit_ms: __sdk::__query_builder::Col, + pub finished_at_ms: __sdk::__query_builder::Col, + pub elapsed_ms: __sdk::__query_builder::Col, + pub clear_count: __sdk::__query_builder::Col, + pub total_item_count: __sdk::__query_builder::Col, + pub cleared_item_count: __sdk::__query_builder::Col, + pub failure_reason: __sdk::__query_builder::Col, + pub snapshot_json: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for Match3DRuntimeRunRow { + type Cols = Match3DRuntimeRunRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + Match3DRuntimeRunRowCols { + run_id: __sdk::__query_builder::Col::new(table_name, "run_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + snapshot_version: __sdk::__query_builder::Col::new(table_name, "snapshot_version"), + started_at_ms: __sdk::__query_builder::Col::new(table_name, "started_at_ms"), + duration_limit_ms: __sdk::__query_builder::Col::new(table_name, "duration_limit_ms"), + finished_at_ms: __sdk::__query_builder::Col::new(table_name, "finished_at_ms"), + elapsed_ms: __sdk::__query_builder::Col::new(table_name, "elapsed_ms"), + clear_count: __sdk::__query_builder::Col::new(table_name, "clear_count"), + total_item_count: __sdk::__query_builder::Col::new(table_name, "total_item_count"), + cleared_item_count: __sdk::__query_builder::Col::new(table_name, "cleared_item_count"), + failure_reason: __sdk::__query_builder::Col::new(table_name, "failure_reason"), + snapshot_json: __sdk::__query_builder::Col::new(table_name, "snapshot_json"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `Match3DRuntimeRunRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct Match3DRuntimeRunRowIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub profile_id: __sdk::__query_builder::IxCol, + pub run_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for Match3DRuntimeRunRow { + type IxCols = Match3DRuntimeRunRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + Match3DRuntimeRunRowIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + run_id: __sdk::__query_builder::IxCol::new(table_name, "run_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for Match3DRuntimeRunRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_delete_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_delete_input_type.rs new file mode 100644 index 00000000..93c4e39f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_delete_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DWorkDeleteInput { + pub profile_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for Match3DWorkDeleteInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_get_input_type.rs new file mode 100644 index 00000000..0bb641ce --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DWorkGetInput { + pub profile_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for Match3DWorkGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_procedure_result_type.rs new file mode 100644 index 00000000..9cb5d518 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_procedure_result_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DWorkProcedureResult { + pub ok: bool, + pub work_json: Option, + pub error_message: Option, +} + +impl __sdk::InModule for Match3DWorkProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_profile_row_type.rs new file mode 100644 index 00000000..750f4c34 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_profile_row_type.rs @@ -0,0 +1,107 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DWorkProfileRow { + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags_json: String, + pub cover_image_src: String, + pub cover_asset_id: String, + pub clear_count: u32, + pub difficulty: u32, + pub config_json: String, + pub publication_status: String, + pub play_count: u32, + pub updated_at: __sdk::Timestamp, + pub published_at: Option<__sdk::Timestamp>, +} + +impl __sdk::InModule for Match3DWorkProfileRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `Match3DWorkProfileRow`. +/// +/// Provides typed access to columns for query building. +pub struct Match3DWorkProfileRowCols { + pub profile_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_session_id: __sdk::__query_builder::Col, + pub author_display_name: __sdk::__query_builder::Col, + pub game_name: __sdk::__query_builder::Col, + pub theme_text: __sdk::__query_builder::Col, + pub summary_text: __sdk::__query_builder::Col, + pub tags_json: __sdk::__query_builder::Col, + pub cover_image_src: __sdk::__query_builder::Col, + pub cover_asset_id: __sdk::__query_builder::Col, + pub clear_count: __sdk::__query_builder::Col, + pub difficulty: __sdk::__query_builder::Col, + pub config_json: __sdk::__query_builder::Col, + pub publication_status: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, + pub published_at: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for Match3DWorkProfileRow { + type Cols = Match3DWorkProfileRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + Match3DWorkProfileRowCols { + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"), + author_display_name: __sdk::__query_builder::Col::new( + table_name, + "author_display_name", + ), + game_name: __sdk::__query_builder::Col::new(table_name, "game_name"), + theme_text: __sdk::__query_builder::Col::new(table_name, "theme_text"), + summary_text: __sdk::__query_builder::Col::new(table_name, "summary_text"), + tags_json: __sdk::__query_builder::Col::new(table_name, "tags_json"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + cover_asset_id: __sdk::__query_builder::Col::new(table_name, "cover_asset_id"), + clear_count: __sdk::__query_builder::Col::new(table_name, "clear_count"), + difficulty: __sdk::__query_builder::Col::new(table_name, "difficulty"), + config_json: __sdk::__query_builder::Col::new(table_name, "config_json"), + publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), + } + } +} + +/// Indexed column accessor struct for the table `Match3DWorkProfileRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct Match3DWorkProfileRowIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub profile_id: __sdk::__query_builder::IxCol, + pub publication_status: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for Match3DWorkProfileRow { + type IxCols = Match3DWorkProfileRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + Match3DWorkProfileRowIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + publication_status: __sdk::__query_builder::IxCol::new( + table_name, + "publication_status", + ), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for Match3DWorkProfileRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_publish_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_publish_input_type.rs new file mode 100644 index 00000000..f13b38b9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_publish_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DWorkPublishInput { + pub profile_id: String, + pub owner_user_id: String, + pub published_at_micros: i64, +} + +impl __sdk::InModule for Match3DWorkPublishInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_update_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_update_input_type.rs new file mode 100644 index 00000000..5f01c7f3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_update_input_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DWorkUpdateInput { + pub profile_id: String, + pub owner_user_id: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags_json: String, + pub cover_image_src: String, + pub cover_asset_id: String, + pub clear_count: u32, + pub difficulty: u32, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for Match3DWorkUpdateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_works_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_works_list_input_type.rs new file mode 100644 index 00000000..a74dfca4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_works_list_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DWorksListInput { + pub owner_user_id: String, + pub published_only: bool, +} + +impl __sdk::InModule for Match3DWorksListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_works_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_works_procedure_result_type.rs new file mode 100644 index 00000000..f1cfd0be --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_works_procedure_result_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DWorksProcedureResult { + pub ok: bool, + pub items_json: Option, + pub error_message: Option, +} + +impl __sdk::InModule for Match3DWorksProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 4dc79691..2a27a6cf 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub mod accept_quest_reducer; pub mod acknowledge_quest_completion_reducer; pub mod admin_disable_profile_redeem_code_procedure; +pub mod admin_upsert_profile_invite_code_procedure; pub mod admin_upsert_profile_redeem_code_procedure; pub mod advance_puzzle_next_level_procedure; pub mod ai_result_reference_input_type; @@ -114,6 +115,8 @@ pub mod big_fish_session_get_input_type; pub mod big_fish_session_procedure_result_type; pub mod big_fish_session_snapshot_type; pub mod big_fish_work_delete_input_type; +pub mod big_fish_work_like_record_input_type; +pub mod big_fish_work_remix_input_type; pub mod big_fish_works_list_input_type; pub mod big_fish_works_procedure_result_type; pub mod bind_asset_object_to_entity_and_return_procedure; @@ -126,10 +129,14 @@ pub mod chapter_progression_ledger_input_type; pub mod chapter_progression_procedure_result_type; pub mod chapter_progression_snapshot_type; pub mod chapter_progression_type; +pub mod claim_puzzle_work_point_incentive_procedure; +pub mod clear_database_migration_import_chunks_procedure; pub mod clear_platform_browse_history_and_return_procedure; +pub mod click_match_3_d_item_procedure; pub mod combat_outcome_type; pub mod compile_big_fish_draft_procedure; pub mod compile_custom_world_published_profile_procedure; +pub mod compile_match_3_d_draft_procedure; pub mod compile_puzzle_agent_draft_procedure; pub mod complete_ai_stage_and_return_procedure; pub mod complete_ai_task_and_return_procedure; @@ -145,6 +152,7 @@ pub mod create_battle_state_and_return_procedure; pub mod create_battle_state_reducer; pub mod create_big_fish_session_procedure; pub mod create_custom_world_agent_session_procedure; +pub mod create_match_3_d_agent_session_procedure; pub mod create_profile_recharge_order_and_return_procedure; pub mod create_puzzle_agent_session_procedure; pub mod custom_world_agent_action_execute_input_type; @@ -179,9 +187,12 @@ pub mod custom_world_generation_mode_type; pub mod custom_world_library_detail_input_type; pub mod custom_world_library_mutation_result_type; pub mod custom_world_profile_delete_input_type; +pub mod custom_world_profile_like_record_input_type; pub mod custom_world_profile_list_input_type; pub mod custom_world_profile_list_result_type; +pub mod custom_world_profile_play_record_input_type; pub mod custom_world_profile_publish_input_type; +pub mod custom_world_profile_remix_input_type; pub mod custom_world_profile_snapshot_type; pub mod custom_world_profile_type; pub mod custom_world_profile_unpublish_input_type; @@ -201,15 +212,21 @@ pub mod custom_world_works_list_input_type; pub mod custom_world_works_list_result_type; pub mod database_migration_authorize_operator_input_type; pub mod database_migration_export_input_type; +pub mod database_migration_import_chunk_input_type; +pub mod database_migration_import_chunk_type; +pub mod database_migration_import_chunks_clear_input_type; +pub mod database_migration_import_chunks_input_type; pub mod database_migration_import_input_type; pub mod database_migration_operator_procedure_result_type; pub mod database_migration_operator_type; pub mod database_migration_procedure_result_type; pub mod database_migration_revoke_operator_input_type; pub mod database_migration_table_stat_type; +pub mod database_migration_warning_type; pub mod delete_big_fish_work_procedure; pub mod delete_custom_world_agent_session_procedure; pub mod delete_custom_world_profile_and_return_procedure; +pub mod delete_match_3_d_work_procedure; pub mod delete_puzzle_work_procedure; pub mod delete_runtime_snapshot_and_return_procedure; pub mod drag_puzzle_piece_or_group_procedure; @@ -220,7 +237,9 @@ pub mod export_database_migration_to_file_procedure; pub mod fail_ai_task_and_return_procedure; pub mod finalize_big_fish_agent_message_turn_procedure; pub mod finalize_custom_world_agent_message_turn_procedure; +pub mod finalize_match_3_d_agent_message_turn_procedure; pub mod finalize_puzzle_agent_message_turn_procedure; +pub mod finish_match_3_d_time_up_procedure; pub mod generate_big_fish_asset_procedure; pub mod get_auth_store_snapshot_procedure; pub mod get_battle_state_procedure; @@ -233,6 +252,9 @@ pub mod get_custom_world_agent_session_procedure; pub mod get_custom_world_gallery_detail_by_code_procedure; pub mod get_custom_world_gallery_detail_procedure; pub mod get_custom_world_library_detail_procedure; +pub mod get_match_3_d_agent_session_procedure; +pub mod get_match_3_d_run_procedure; +pub mod get_match_3_d_work_detail_procedure; pub mod get_player_progression_or_default_procedure; pub mod get_profile_dashboard_procedure; pub mod get_profile_play_stats_procedure; @@ -247,10 +269,13 @@ pub mod get_runtime_setting_or_default_procedure; pub mod get_runtime_snapshot_procedure; pub mod get_story_session_state_procedure; pub mod grant_inventory_item_input_type; +pub mod grant_new_user_registration_wallet_reward_procedure; pub mod grant_player_progression_experience_and_return_procedure; pub mod grant_player_progression_experience_reducer; pub mod import_auth_store_snapshot_procedure; +pub mod import_database_migration_from_chunks_procedure; pub mod import_database_migration_from_file_procedure; +pub mod import_database_migration_incremental_from_chunks_procedure; pub mod import_database_migration_incremental_from_file_procedure; pub mod inventory_container_kind_type; pub mod inventory_equipment_slot_type; @@ -266,11 +291,37 @@ pub mod list_big_fish_works_procedure; pub mod list_custom_world_gallery_entries_procedure; pub mod list_custom_world_profiles_procedure; pub mod list_custom_world_works_procedure; +pub mod list_match_3_d_works_procedure; pub mod list_platform_browse_history_procedure; pub mod list_profile_save_archives_procedure; pub mod list_profile_wallet_ledger_procedure; pub mod list_puzzle_gallery_procedure; pub mod list_puzzle_works_procedure; +pub mod match_3_d_agent_message_finalize_input_type; +pub mod match_3_d_agent_message_row_type; +pub mod match_3_d_agent_message_submit_input_type; +pub mod match_3_d_agent_session_create_input_type; +pub mod match_3_d_agent_session_get_input_type; +pub mod match_3_d_agent_session_procedure_result_type; +pub mod match_3_d_agent_session_row_type; +pub mod match_3_d_click_item_procedure_result_type; +pub mod match_3_d_draft_compile_input_type; +pub mod match_3_d_run_click_input_type; +pub mod match_3_d_run_get_input_type; +pub mod match_3_d_run_procedure_result_type; +pub mod match_3_d_run_restart_input_type; +pub mod match_3_d_run_start_input_type; +pub mod match_3_d_run_stop_input_type; +pub mod match_3_d_run_time_up_input_type; +pub mod match_3_d_runtime_run_row_type; +pub mod match_3_d_work_delete_input_type; +pub mod match_3_d_work_get_input_type; +pub mod match_3_d_work_procedure_result_type; +pub mod match_3_d_work_profile_row_type; +pub mod match_3_d_work_publish_input_type; +pub mod match_3_d_work_update_input_type; +pub mod match_3_d_works_list_input_type; +pub mod match_3_d_works_procedure_result_type; pub mod npc_battle_interaction_procedure_result_type; pub mod npc_battle_interaction_result_type; pub mod npc_interaction_battle_mode_type; @@ -301,11 +352,15 @@ pub mod profile_redeem_code_usage_type; pub mod profile_referral_relation_type; pub mod profile_save_archive_type; pub mod profile_wallet_ledger_type; +pub mod public_work_like_type; +pub mod public_work_play_daily_stat_type; pub mod publish_big_fish_game_procedure; pub mod publish_custom_world_profile_and_return_procedure; pub mod publish_custom_world_profile_reducer; pub mod publish_custom_world_world_procedure; +pub mod publish_match_3_d_work_procedure; pub mod publish_puzzle_work_procedure; +pub mod put_database_migration_import_chunk_procedure; pub mod puzzle_agent_message_finalize_input_type; pub mod puzzle_agent_message_kind_type; pub mod puzzle_agent_message_role_type; @@ -320,6 +375,7 @@ pub mod puzzle_draft_compile_input_type; pub mod puzzle_event_kind_type; pub mod puzzle_event_table; pub mod puzzle_event_type; +pub mod puzzle_form_draft_save_input_type; pub mod puzzle_generated_images_save_input_type; pub mod puzzle_leaderboard_entry_row_type; pub mod puzzle_leaderboard_submit_input_type; @@ -328,15 +384,20 @@ pub mod puzzle_publish_input_type; pub mod puzzle_run_drag_input_type; pub mod puzzle_run_get_input_type; pub mod puzzle_run_next_level_input_type; +pub mod puzzle_run_pause_input_type; pub mod puzzle_run_procedure_result_type; +pub mod puzzle_run_prop_input_type; pub mod puzzle_run_start_input_type; pub mod puzzle_run_swap_input_type; pub mod puzzle_runtime_run_row_type; pub mod puzzle_select_cover_image_input_type; pub mod puzzle_work_delete_input_type; pub mod puzzle_work_get_input_type; +pub mod puzzle_work_like_record_input_type; +pub mod puzzle_work_point_incentive_claim_input_type; pub mod puzzle_work_procedure_result_type; pub mod puzzle_work_profile_row_type; +pub mod puzzle_work_remix_input_type; pub mod puzzle_work_upsert_input_type; pub mod puzzle_works_list_input_type; pub mod puzzle_works_procedure_result_type; @@ -367,11 +428,18 @@ pub mod quest_status_type; pub mod quest_step_snapshot_type; pub mod quest_treasure_inspected_signal_type; pub mod quest_turn_in_input_type; +pub mod record_big_fish_like_procedure; pub mod record_big_fish_play_procedure; +pub mod record_custom_world_profile_like_procedure; +pub mod record_custom_world_profile_play_procedure; +pub mod record_puzzle_work_like_procedure; pub mod redeem_profile_referral_invite_code_procedure; pub mod redeem_profile_reward_code_procedure; pub mod refresh_session_type; pub mod refund_profile_wallet_points_and_return_procedure; +pub mod remix_big_fish_work_procedure; +pub mod remix_custom_world_profile_procedure; +pub mod remix_puzzle_work_procedure; pub mod resolve_combat_action_and_return_procedure; pub mod resolve_combat_action_input_type; pub mod resolve_combat_action_procedure_result_type; @@ -387,6 +455,7 @@ pub mod resolve_npc_social_action_input_type; pub mod resolve_npc_social_action_reducer; pub mod resolve_treasure_interaction_and_return_procedure; pub mod resolve_treasure_interaction_reducer; +pub mod restart_match_3_d_run_procedure; pub mod resume_profile_save_archive_and_return_procedure; pub mod revoke_database_migration_operator_procedure; pub mod rpg_agent_draft_card_kind_type; @@ -413,6 +482,9 @@ pub mod runtime_platform_theme_type; pub mod runtime_profile_dashboard_get_input_type; pub mod runtime_profile_dashboard_procedure_result_type; pub mod runtime_profile_dashboard_snapshot_type; +pub mod runtime_profile_invite_code_admin_procedure_result_type; +pub mod runtime_profile_invite_code_admin_upsert_input_type; +pub mod runtime_profile_invite_code_snapshot_type; pub mod runtime_profile_membership_benefit_snapshot_type; pub mod runtime_profile_membership_snapshot_type; pub mod runtime_profile_membership_status_type; @@ -450,6 +522,7 @@ pub mod runtime_profile_wallet_ledger_source_type_type; pub mod runtime_referral_invite_center_get_input_type; pub mod runtime_referral_invite_center_procedure_result_type; pub mod runtime_referral_invite_center_snapshot_type; +pub mod runtime_referral_invited_user_snapshot_type; pub mod runtime_referral_redeem_input_type; pub mod runtime_referral_redeem_procedure_result_type; pub mod runtime_referral_redeem_snapshot_type; @@ -464,12 +537,15 @@ pub mod runtime_snapshot_procedure_result_type; pub mod runtime_snapshot_row_type; pub mod runtime_snapshot_type; pub mod runtime_snapshot_upsert_input_type; +pub mod save_puzzle_form_draft_procedure; pub mod save_puzzle_generated_images_procedure; pub mod select_puzzle_cover_image_procedure; pub mod start_ai_task_reducer; pub mod start_ai_task_stage_reducer; pub mod start_big_fish_run_procedure; +pub mod start_match_3_d_run_procedure; pub mod start_puzzle_run_procedure; +pub mod stop_match_3_d_run_procedure; pub mod story_continue_input_type; pub mod story_event_kind_type; pub mod story_event_snapshot_type; @@ -484,6 +560,7 @@ pub mod story_session_type; pub mod submit_big_fish_input_procedure; pub mod submit_big_fish_message_procedure; pub mod submit_custom_world_agent_message_procedure; +pub mod submit_match_3_d_agent_message_procedure; pub mod submit_puzzle_agent_message_procedure; pub mod submit_puzzle_leaderboard_entry_procedure; pub mod swap_puzzle_pieces_procedure; @@ -496,6 +573,8 @@ pub mod turn_in_quest_reducer; pub mod unequip_inventory_item_input_type; pub mod unpublish_custom_world_profile_and_return_procedure; pub mod unpublish_custom_world_profile_reducer; +pub mod update_match_3_d_work_procedure; +pub mod update_puzzle_run_pause_procedure; pub mod update_puzzle_work_procedure; pub mod upsert_auth_store_snapshot_procedure; pub mod upsert_chapter_progression_and_return_procedure; @@ -508,12 +587,14 @@ pub mod upsert_npc_state_reducer; pub mod upsert_platform_browse_history_and_return_procedure; pub mod upsert_runtime_setting_and_return_procedure; pub mod upsert_runtime_snapshot_and_return_procedure; +pub mod use_puzzle_runtime_prop_procedure; pub mod user_account_type; pub mod user_browse_history_type; pub use accept_quest_reducer::accept_quest; pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code; +pub use admin_upsert_profile_invite_code_procedure::admin_upsert_profile_invite_code; pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code; pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level; pub use ai_result_reference_input_type::AiResultReferenceInput; @@ -619,6 +700,8 @@ pub use big_fish_session_get_input_type::BigFishSessionGetInput; pub use big_fish_session_procedure_result_type::BigFishSessionProcedureResult; pub use big_fish_session_snapshot_type::BigFishSessionSnapshot; pub use big_fish_work_delete_input_type::BigFishWorkDeleteInput; +pub use big_fish_work_like_record_input_type::BigFishWorkLikeRecordInput; +pub use big_fish_work_remix_input_type::BigFishWorkRemixInput; pub use big_fish_works_list_input_type::BigFishWorksListInput; pub use big_fish_works_procedure_result_type::BigFishWorksProcedureResult; pub use bind_asset_object_to_entity_and_return_procedure::bind_asset_object_to_entity_and_return; @@ -631,10 +714,14 @@ pub use chapter_progression_ledger_input_type::ChapterProgressionLedgerInput; pub use chapter_progression_procedure_result_type::ChapterProgressionProcedureResult; pub use chapter_progression_snapshot_type::ChapterProgressionSnapshot; pub use chapter_progression_type::ChapterProgression; +pub use claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive; +pub use clear_database_migration_import_chunks_procedure::clear_database_migration_import_chunks; pub use clear_platform_browse_history_and_return_procedure::clear_platform_browse_history_and_return; +pub use click_match_3_d_item_procedure::click_match_3_d_item; pub use combat_outcome_type::CombatOutcome; pub use compile_big_fish_draft_procedure::compile_big_fish_draft; pub use compile_custom_world_published_profile_procedure::compile_custom_world_published_profile; +pub use compile_match_3_d_draft_procedure::compile_match_3_d_draft; pub use compile_puzzle_agent_draft_procedure::compile_puzzle_agent_draft; pub use complete_ai_stage_and_return_procedure::complete_ai_stage_and_return; pub use complete_ai_task_and_return_procedure::complete_ai_task_and_return; @@ -650,6 +737,7 @@ pub use create_battle_state_and_return_procedure::create_battle_state_and_return pub use create_battle_state_reducer::create_battle_state; pub use create_big_fish_session_procedure::create_big_fish_session; pub use create_custom_world_agent_session_procedure::create_custom_world_agent_session; +pub use create_match_3_d_agent_session_procedure::create_match_3_d_agent_session; pub use create_profile_recharge_order_and_return_procedure::create_profile_recharge_order_and_return; pub use create_puzzle_agent_session_procedure::create_puzzle_agent_session; pub use custom_world_agent_action_execute_input_type::CustomWorldAgentActionExecuteInput; @@ -684,9 +772,12 @@ pub use custom_world_generation_mode_type::CustomWorldGenerationMode; pub use custom_world_library_detail_input_type::CustomWorldLibraryDetailInput; pub use custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; pub use custom_world_profile_delete_input_type::CustomWorldProfileDeleteInput; +pub use custom_world_profile_like_record_input_type::CustomWorldProfileLikeRecordInput; pub use custom_world_profile_list_input_type::CustomWorldProfileListInput; pub use custom_world_profile_list_result_type::CustomWorldProfileListResult; +pub use custom_world_profile_play_record_input_type::CustomWorldProfilePlayRecordInput; pub use custom_world_profile_publish_input_type::CustomWorldProfilePublishInput; +pub use custom_world_profile_remix_input_type::CustomWorldProfileRemixInput; pub use custom_world_profile_snapshot_type::CustomWorldProfileSnapshot; pub use custom_world_profile_type::CustomWorldProfile; pub use custom_world_profile_unpublish_input_type::CustomWorldProfileUnpublishInput; @@ -706,15 +797,21 @@ pub use custom_world_works_list_input_type::CustomWorldWorksListInput; pub use custom_world_works_list_result_type::CustomWorldWorksListResult; pub use database_migration_authorize_operator_input_type::DatabaseMigrationAuthorizeOperatorInput; pub use database_migration_export_input_type::DatabaseMigrationExportInput; +pub use database_migration_import_chunk_input_type::DatabaseMigrationImportChunkInput; +pub use database_migration_import_chunk_type::DatabaseMigrationImportChunk; +pub use database_migration_import_chunks_clear_input_type::DatabaseMigrationImportChunksClearInput; +pub use database_migration_import_chunks_input_type::DatabaseMigrationImportChunksInput; pub use database_migration_import_input_type::DatabaseMigrationImportInput; pub use database_migration_operator_procedure_result_type::DatabaseMigrationOperatorProcedureResult; pub use database_migration_operator_type::DatabaseMigrationOperator; pub use database_migration_procedure_result_type::DatabaseMigrationProcedureResult; pub use database_migration_revoke_operator_input_type::DatabaseMigrationRevokeOperatorInput; pub use database_migration_table_stat_type::DatabaseMigrationTableStat; +pub use database_migration_warning_type::DatabaseMigrationWarning; pub use delete_big_fish_work_procedure::delete_big_fish_work; pub use delete_custom_world_agent_session_procedure::delete_custom_world_agent_session; pub use delete_custom_world_profile_and_return_procedure::delete_custom_world_profile_and_return; +pub use delete_match_3_d_work_procedure::delete_match_3_d_work; pub use delete_puzzle_work_procedure::delete_puzzle_work; pub use delete_runtime_snapshot_and_return_procedure::delete_runtime_snapshot_and_return; pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group; @@ -725,7 +822,9 @@ pub use export_database_migration_to_file_procedure::export_database_migration_t pub use fail_ai_task_and_return_procedure::fail_ai_task_and_return; pub use finalize_big_fish_agent_message_turn_procedure::finalize_big_fish_agent_message_turn; pub use finalize_custom_world_agent_message_turn_procedure::finalize_custom_world_agent_message_turn; +pub use finalize_match_3_d_agent_message_turn_procedure::finalize_match_3_d_agent_message_turn; pub use finalize_puzzle_agent_message_turn_procedure::finalize_puzzle_agent_message_turn; +pub use finish_match_3_d_time_up_procedure::finish_match_3_d_time_up; pub use generate_big_fish_asset_procedure::generate_big_fish_asset; pub use get_auth_store_snapshot_procedure::get_auth_store_snapshot; pub use get_battle_state_procedure::get_battle_state; @@ -738,6 +837,9 @@ pub use get_custom_world_agent_session_procedure::get_custom_world_agent_session pub use get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gallery_detail_by_code; pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail; pub use get_custom_world_library_detail_procedure::get_custom_world_library_detail; +pub use get_match_3_d_agent_session_procedure::get_match_3_d_agent_session; +pub use get_match_3_d_run_procedure::get_match_3_d_run; +pub use get_match_3_d_work_detail_procedure::get_match_3_d_work_detail; pub use get_player_progression_or_default_procedure::get_player_progression_or_default; pub use get_profile_dashboard_procedure::get_profile_dashboard; pub use get_profile_play_stats_procedure::get_profile_play_stats; @@ -752,10 +854,13 @@ pub use get_runtime_setting_or_default_procedure::get_runtime_setting_or_default pub use get_runtime_snapshot_procedure::get_runtime_snapshot; pub use get_story_session_state_procedure::get_story_session_state; pub use grant_inventory_item_input_type::GrantInventoryItemInput; +pub use grant_new_user_registration_wallet_reward_procedure::grant_new_user_registration_wallet_reward; pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return; pub use grant_player_progression_experience_reducer::grant_player_progression_experience; pub use import_auth_store_snapshot_procedure::import_auth_store_snapshot; +pub use import_database_migration_from_chunks_procedure::import_database_migration_from_chunks; pub use import_database_migration_from_file_procedure::import_database_migration_from_file; +pub use import_database_migration_incremental_from_chunks_procedure::import_database_migration_incremental_from_chunks; pub use import_database_migration_incremental_from_file_procedure::import_database_migration_incremental_from_file; pub use inventory_container_kind_type::InventoryContainerKind; pub use inventory_equipment_slot_type::InventoryEquipmentSlot; @@ -771,11 +876,37 @@ pub use list_big_fish_works_procedure::list_big_fish_works; pub use list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries; pub use list_custom_world_profiles_procedure::list_custom_world_profiles; pub use list_custom_world_works_procedure::list_custom_world_works; +pub use list_match_3_d_works_procedure::list_match_3_d_works; pub use list_platform_browse_history_procedure::list_platform_browse_history; pub use list_profile_save_archives_procedure::list_profile_save_archives; pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger; pub use list_puzzle_gallery_procedure::list_puzzle_gallery; pub use list_puzzle_works_procedure::list_puzzle_works; +pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput; +pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow; +pub use match_3_d_agent_message_submit_input_type::Match3DAgentMessageSubmitInput; +pub use match_3_d_agent_session_create_input_type::Match3DAgentSessionCreateInput; +pub use match_3_d_agent_session_get_input_type::Match3DAgentSessionGetInput; +pub use match_3_d_agent_session_procedure_result_type::Match3DAgentSessionProcedureResult; +pub use match_3_d_agent_session_row_type::Match3DAgentSessionRow; +pub use match_3_d_click_item_procedure_result_type::Match3DClickItemProcedureResult; +pub use match_3_d_draft_compile_input_type::Match3DDraftCompileInput; +pub use match_3_d_run_click_input_type::Match3DRunClickInput; +pub use match_3_d_run_get_input_type::Match3DRunGetInput; +pub use match_3_d_run_procedure_result_type::Match3DRunProcedureResult; +pub use match_3_d_run_restart_input_type::Match3DRunRestartInput; +pub use match_3_d_run_start_input_type::Match3DRunStartInput; +pub use match_3_d_run_stop_input_type::Match3DRunStopInput; +pub use match_3_d_run_time_up_input_type::Match3DRunTimeUpInput; +pub use match_3_d_runtime_run_row_type::Match3DRuntimeRunRow; +pub use match_3_d_work_delete_input_type::Match3DWorkDeleteInput; +pub use match_3_d_work_get_input_type::Match3DWorkGetInput; +pub use match_3_d_work_procedure_result_type::Match3DWorkProcedureResult; +pub use match_3_d_work_profile_row_type::Match3DWorkProfileRow; +pub use match_3_d_work_publish_input_type::Match3DWorkPublishInput; +pub use match_3_d_work_update_input_type::Match3DWorkUpdateInput; +pub use match_3_d_works_list_input_type::Match3DWorksListInput; +pub use match_3_d_works_procedure_result_type::Match3DWorksProcedureResult; pub use npc_battle_interaction_procedure_result_type::NpcBattleInteractionProcedureResult; pub use npc_battle_interaction_result_type::NpcBattleInteractionResult; pub use npc_interaction_battle_mode_type::NpcInteractionBattleMode; @@ -806,11 +937,15 @@ pub use profile_redeem_code_usage_type::ProfileRedeemCodeUsage; pub use profile_referral_relation_type::ProfileReferralRelation; pub use profile_save_archive_type::ProfileSaveArchive; pub use profile_wallet_ledger_type::ProfileWalletLedger; +pub use public_work_like_type::PublicWorkLike; +pub use public_work_play_daily_stat_type::PublicWorkPlayDailyStat; pub use publish_big_fish_game_procedure::publish_big_fish_game; pub use publish_custom_world_profile_and_return_procedure::publish_custom_world_profile_and_return; pub use publish_custom_world_profile_reducer::publish_custom_world_profile; pub use publish_custom_world_world_procedure::publish_custom_world_world; +pub use publish_match_3_d_work_procedure::publish_match_3_d_work; pub use publish_puzzle_work_procedure::publish_puzzle_work; +pub use put_database_migration_import_chunk_procedure::put_database_migration_import_chunk; pub use puzzle_agent_message_finalize_input_type::PuzzleAgentMessageFinalizeInput; pub use puzzle_agent_message_kind_type::PuzzleAgentMessageKind; pub use puzzle_agent_message_role_type::PuzzleAgentMessageRole; @@ -825,6 +960,7 @@ pub use puzzle_draft_compile_input_type::PuzzleDraftCompileInput; pub use puzzle_event_kind_type::PuzzleEventKind; pub use puzzle_event_table::*; pub use puzzle_event_type::PuzzleEvent; +pub use puzzle_form_draft_save_input_type::PuzzleFormDraftSaveInput; pub use puzzle_generated_images_save_input_type::PuzzleGeneratedImagesSaveInput; pub use puzzle_leaderboard_entry_row_type::PuzzleLeaderboardEntryRow; pub use puzzle_leaderboard_submit_input_type::PuzzleLeaderboardSubmitInput; @@ -833,15 +969,20 @@ pub use puzzle_publish_input_type::PuzzlePublishInput; pub use puzzle_run_drag_input_type::PuzzleRunDragInput; pub use puzzle_run_get_input_type::PuzzleRunGetInput; pub use puzzle_run_next_level_input_type::PuzzleRunNextLevelInput; +pub use puzzle_run_pause_input_type::PuzzleRunPauseInput; pub use puzzle_run_procedure_result_type::PuzzleRunProcedureResult; +pub use puzzle_run_prop_input_type::PuzzleRunPropInput; pub use puzzle_run_start_input_type::PuzzleRunStartInput; pub use puzzle_run_swap_input_type::PuzzleRunSwapInput; pub use puzzle_runtime_run_row_type::PuzzleRuntimeRunRow; pub use puzzle_select_cover_image_input_type::PuzzleSelectCoverImageInput; pub use puzzle_work_delete_input_type::PuzzleWorkDeleteInput; pub use puzzle_work_get_input_type::PuzzleWorkGetInput; +pub use puzzle_work_like_record_input_type::PuzzleWorkLikeRecordInput; +pub use puzzle_work_point_incentive_claim_input_type::PuzzleWorkPointIncentiveClaimInput; pub use puzzle_work_procedure_result_type::PuzzleWorkProcedureResult; pub use puzzle_work_profile_row_type::PuzzleWorkProfileRow; +pub use puzzle_work_remix_input_type::PuzzleWorkRemixInput; pub use puzzle_work_upsert_input_type::PuzzleWorkUpsertInput; pub use puzzle_works_list_input_type::PuzzleWorksListInput; pub use puzzle_works_procedure_result_type::PuzzleWorksProcedureResult; @@ -872,11 +1013,18 @@ pub use quest_status_type::QuestStatus; pub use quest_step_snapshot_type::QuestStepSnapshot; pub use quest_treasure_inspected_signal_type::QuestTreasureInspectedSignal; pub use quest_turn_in_input_type::QuestTurnInInput; +pub use record_big_fish_like_procedure::record_big_fish_like; pub use record_big_fish_play_procedure::record_big_fish_play; +pub use record_custom_world_profile_like_procedure::record_custom_world_profile_like; +pub use record_custom_world_profile_play_procedure::record_custom_world_profile_play; +pub use record_puzzle_work_like_procedure::record_puzzle_work_like; pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code; pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code; pub use refresh_session_type::RefreshSession; pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; +pub use remix_big_fish_work_procedure::remix_big_fish_work; +pub use remix_custom_world_profile_procedure::remix_custom_world_profile; +pub use remix_puzzle_work_procedure::remix_puzzle_work; pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return; pub use resolve_combat_action_input_type::ResolveCombatActionInput; pub use resolve_combat_action_procedure_result_type::ResolveCombatActionProcedureResult; @@ -892,6 +1040,7 @@ pub use resolve_npc_social_action_input_type::ResolveNpcSocialActionInput; pub use resolve_npc_social_action_reducer::resolve_npc_social_action; pub use resolve_treasure_interaction_and_return_procedure::resolve_treasure_interaction_and_return; pub use resolve_treasure_interaction_reducer::resolve_treasure_interaction; +pub use restart_match_3_d_run_procedure::restart_match_3_d_run; pub use resume_profile_save_archive_and_return_procedure::resume_profile_save_archive_and_return; pub use revoke_database_migration_operator_procedure::revoke_database_migration_operator; pub use rpg_agent_draft_card_kind_type::RpgAgentDraftCardKind; @@ -918,6 +1067,9 @@ pub use runtime_platform_theme_type::RuntimePlatformTheme; pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput; pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult; pub use runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot; +pub use runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult; +pub use runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInviteCodeAdminUpsertInput; +pub use runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; pub use runtime_profile_membership_benefit_snapshot_type::RuntimeProfileMembershipBenefitSnapshot; pub use runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot; pub use runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; @@ -955,6 +1107,7 @@ pub use runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedg pub use runtime_referral_invite_center_get_input_type::RuntimeReferralInviteCenterGetInput; pub use runtime_referral_invite_center_procedure_result_type::RuntimeReferralInviteCenterProcedureResult; pub use runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot; +pub use runtime_referral_invited_user_snapshot_type::RuntimeReferralInvitedUserSnapshot; pub use runtime_referral_redeem_input_type::RuntimeReferralRedeemInput; pub use runtime_referral_redeem_procedure_result_type::RuntimeReferralRedeemProcedureResult; pub use runtime_referral_redeem_snapshot_type::RuntimeReferralRedeemSnapshot; @@ -969,12 +1122,15 @@ pub use runtime_snapshot_procedure_result_type::RuntimeSnapshotProcedureResult; pub use runtime_snapshot_row_type::RuntimeSnapshotRow; pub use runtime_snapshot_type::RuntimeSnapshot; pub use runtime_snapshot_upsert_input_type::RuntimeSnapshotUpsertInput; +pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft; pub use save_puzzle_generated_images_procedure::save_puzzle_generated_images; pub use select_puzzle_cover_image_procedure::select_puzzle_cover_image; pub use start_ai_task_reducer::start_ai_task; pub use start_ai_task_stage_reducer::start_ai_task_stage; pub use start_big_fish_run_procedure::start_big_fish_run; +pub use start_match_3_d_run_procedure::start_match_3_d_run; pub use start_puzzle_run_procedure::start_puzzle_run; +pub use stop_match_3_d_run_procedure::stop_match_3_d_run; pub use story_continue_input_type::StoryContinueInput; pub use story_event_kind_type::StoryEventKind; pub use story_event_snapshot_type::StoryEventSnapshot; @@ -989,6 +1145,7 @@ pub use story_session_type::StorySession; pub use submit_big_fish_input_procedure::submit_big_fish_input; pub use submit_big_fish_message_procedure::submit_big_fish_message; pub use submit_custom_world_agent_message_procedure::submit_custom_world_agent_message; +pub use submit_match_3_d_agent_message_procedure::submit_match_3_d_agent_message; pub use submit_puzzle_agent_message_procedure::submit_puzzle_agent_message; pub use submit_puzzle_leaderboard_entry_procedure::submit_puzzle_leaderboard_entry; pub use swap_puzzle_pieces_procedure::swap_puzzle_pieces; @@ -1001,6 +1158,8 @@ pub use turn_in_quest_reducer::turn_in_quest; pub use unequip_inventory_item_input_type::UnequipInventoryItemInput; pub use unpublish_custom_world_profile_and_return_procedure::unpublish_custom_world_profile_and_return; pub use unpublish_custom_world_profile_reducer::unpublish_custom_world_profile; +pub use update_match_3_d_work_procedure::update_match_3_d_work; +pub use update_puzzle_run_pause_procedure::update_puzzle_run_pause; pub use update_puzzle_work_procedure::update_puzzle_work; pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot; pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return; @@ -1013,6 +1172,7 @@ pub use upsert_npc_state_reducer::upsert_npc_state; pub use upsert_platform_browse_history_and_return_procedure::upsert_platform_browse_history_and_return; pub use upsert_runtime_setting_and_return_procedure::upsert_runtime_setting_and_return; pub use upsert_runtime_snapshot_and_return_procedure::upsert_runtime_snapshot_and_return; +pub use use_puzzle_runtime_prop_procedure::use_puzzle_runtime_prop; pub use user_account_type::UserAccount; pub use user_browse_history_type::UserBrowseHistory; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs index e556d2f7..3ded53b9 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs @@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub struct ProfileInviteCode { pub user_id: String, pub invite_code: String, + pub metadata_json: String, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, } @@ -23,6 +24,7 @@ impl __sdk::InModule for ProfileInviteCode { pub struct ProfileInviteCodeCols { pub user_id: __sdk::__query_builder::Col, pub invite_code: __sdk::__query_builder::Col, + pub metadata_json: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, } @@ -33,6 +35,7 @@ impl __sdk::__query_builder::HasCols for ProfileInviteCode { ProfileInviteCodeCols { user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), invite_code: __sdk::__query_builder::Col::new(table_name, "invite_code"), + metadata_json: __sdk::__query_builder::Col::new(table_name, "metadata_json"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/public_work_like_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/public_work_like_type.rs new file mode 100644 index 00000000..9162c1ba --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/public_work_like_type.rs @@ -0,0 +1,66 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PublicWorkLike { + pub like_id: String, + pub source_type: String, + pub owner_user_id: String, + pub profile_id: String, + pub user_id: String, + pub liked_at: __sdk::Timestamp, +} + +impl __sdk::InModule for PublicWorkLike { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PublicWorkLike`. +/// +/// Provides typed access to columns for query building. +pub struct PublicWorkLikeCols { + pub like_id: __sdk::__query_builder::Col, + pub source_type: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub user_id: __sdk::__query_builder::Col, + pub liked_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PublicWorkLike { + type Cols = PublicWorkLikeCols; + fn cols(table_name: &'static str) -> Self::Cols { + PublicWorkLikeCols { + like_id: __sdk::__query_builder::Col::new(table_name, "like_id"), + source_type: __sdk::__query_builder::Col::new(table_name, "source_type"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + liked_at: __sdk::__query_builder::Col::new(table_name, "liked_at"), + } + } +} + +/// Indexed column accessor struct for the table `PublicWorkLike`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PublicWorkLikeIxCols { + pub like_id: __sdk::__query_builder::IxCol, + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PublicWorkLike { + type IxCols = PublicWorkLikeIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PublicWorkLikeIxCols { + like_id: __sdk::__query_builder::IxCol::new(table_name, "like_id"), + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PublicWorkLike {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/public_work_play_daily_stat_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/public_work_play_daily_stat_type.rs new file mode 100644 index 00000000..525b7698 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/public_work_play_daily_stat_type.rs @@ -0,0 +1,67 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PublicWorkPlayDailyStat { + pub stat_id: String, + pub source_type: String, + pub owner_user_id: String, + pub profile_id: String, + pub played_day: i64, + pub play_count: u32, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for PublicWorkPlayDailyStat { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PublicWorkPlayDailyStat`. +/// +/// Provides typed access to columns for query building. +pub struct PublicWorkPlayDailyStatCols { + pub stat_id: __sdk::__query_builder::Col, + pub source_type: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub played_day: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PublicWorkPlayDailyStat { + type Cols = PublicWorkPlayDailyStatCols; + fn cols(table_name: &'static str) -> Self::Cols { + PublicWorkPlayDailyStatCols { + stat_id: __sdk::__query_builder::Col::new(table_name, "stat_id"), + source_type: __sdk::__query_builder::Col::new(table_name, "source_type"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + played_day: __sdk::__query_builder::Col::new(table_name, "played_day"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `PublicWorkPlayDailyStat`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PublicWorkPlayDailyStatIxCols { + pub stat_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PublicWorkPlayDailyStat { + type IxCols = PublicWorkPlayDailyStatIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PublicWorkPlayDailyStatIxCols { + stat_id: __sdk::__query_builder::IxCol::new(table_name, "stat_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PublicWorkPlayDailyStat {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/publish_match_3_d_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/publish_match_3_d_work_procedure.rs new file mode 100644 index 00000000..db0c7efe --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/publish_match_3_d_work_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_work_procedure_result_type::Match3DWorkProcedureResult; +use super::match_3_d_work_publish_input_type::Match3DWorkPublishInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PublishMatch3DWorkArgs { + pub input: Match3DWorkPublishInput, +} + +impl __sdk::InModule for PublishMatch3DWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `publish_match_3_d_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait publish_match_3_d_work { + fn publish_match_3_d_work(&self, input: Match3DWorkPublishInput) { + self.publish_match_3_d_work_then(input, |_, _| {}); + } + + fn publish_match_3_d_work_then( + &self, + input: Match3DWorkPublishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl publish_match_3_d_work for super::RemoteProcedures { + fn publish_match_3_d_work_then( + &self, + input: Match3DWorkPublishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DWorkProcedureResult>( + "publish_match_3_d_work", + PublishMatch3DWorkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/put_database_migration_import_chunk_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/put_database_migration_import_chunk_procedure.rs new file mode 100644 index 00000000..f3776bfd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/put_database_migration_import_chunk_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::database_migration_import_chunk_input_type::DatabaseMigrationImportChunkInput; +use super::database_migration_procedure_result_type::DatabaseMigrationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PutDatabaseMigrationImportChunkArgs { + pub input: DatabaseMigrationImportChunkInput, +} + +impl __sdk::InModule for PutDatabaseMigrationImportChunkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `put_database_migration_import_chunk`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait put_database_migration_import_chunk { + fn put_database_migration_import_chunk(&self, input: DatabaseMigrationImportChunkInput) { + self.put_database_migration_import_chunk_then(input, |_, _| {}); + } + + fn put_database_migration_import_chunk_then( + &self, + input: DatabaseMigrationImportChunkInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl put_database_migration_import_chunk for super::RemoteProcedures { + fn put_database_migration_import_chunk_then( + &self, + input: DatabaseMigrationImportChunkInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( + "put_database_migration_import_chunk", + PutDatabaseMigrationImportChunkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_form_draft_save_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_form_draft_save_input_type.rs new file mode 100644 index 00000000..cbb69d06 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_form_draft_save_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleFormDraftSaveInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub saved_at_micros: i64, +} + +impl __sdk::InModule for PuzzleFormDraftSaveInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs index 8c409aed..f5debfcd 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs @@ -9,6 +9,8 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub struct PuzzleGeneratedImagesSaveInput { pub session_id: String, pub owner_user_id: String, + pub level_id: Option, + pub levels_json: Option, pub candidates_json: String, pub saved_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_publish_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_publish_input_type.rs index 83cbc37c..2e41a72e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_publish_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_publish_input_type.rs @@ -12,9 +12,12 @@ pub struct PuzzlePublishInput { pub work_id: String, pub profile_id: String, pub author_display_name: String, + pub work_title: Option, + pub work_description: Option, pub level_name: Option, pub summary: Option, pub theme_tags: Option>, + pub levels_json: Option, pub published_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_pause_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_pause_input_type.rs new file mode 100644 index 00000000..33a34776 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_pause_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleRunPauseInput { + pub run_id: String, + pub owner_user_id: String, + pub paused: bool, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PuzzleRunPauseInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_prop_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_prop_input_type.rs new file mode 100644 index 00000000..1d8ddb25 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_prop_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleRunPropInput { + pub run_id: String, + pub owner_user_id: String, + pub prop_kind: String, + pub used_at_micros: i64, + pub spent_points: u64, +} + +impl __sdk::InModule for PuzzleRunPropInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_start_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_start_input_type.rs index 7b4bed29..8e1734e4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_start_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_start_input_type.rs @@ -10,6 +10,7 @@ pub struct PuzzleRunStartInput { pub run_id: String, pub owner_user_id: String, pub profile_id: String, + pub level_id: Option, pub started_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_select_cover_image_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_select_cover_image_input_type.rs index 516d16b9..f051f8a4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_select_cover_image_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_select_cover_image_input_type.rs @@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub struct PuzzleSelectCoverImageInput { pub session_id: String, pub owner_user_id: String, + pub level_id: Option, pub candidate_id: String, pub selected_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_like_record_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_like_record_input_type.rs new file mode 100644 index 00000000..5574ae3f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_like_record_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleWorkLikeRecordInput { + pub profile_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + +impl __sdk::InModule for PuzzleWorkLikeRecordInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_point_incentive_claim_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_point_incentive_claim_input_type.rs new file mode 100644 index 00000000..c73ecb26 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_point_incentive_claim_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleWorkPointIncentiveClaimInput { + pub profile_id: String, + pub owner_user_id: String, + pub claimed_at_micros: i64, +} + +impl __sdk::InModule for PuzzleWorkPointIncentiveClaimInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs index be53fc17..a2ebd389 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs @@ -14,11 +14,14 @@ pub struct PuzzleWorkProfileRow { pub owner_user_id: String, pub source_session_id: Option, pub author_display_name: String, + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags_json: String, pub cover_image_src: Option, pub cover_asset_id: Option, + pub levels_json: String, pub publication_status: PuzzlePublicationStatus, pub play_count: u32, pub anchor_pack_json: String, @@ -26,6 +29,10 @@ pub struct PuzzleWorkProfileRow { pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, pub published_at: Option<__sdk::Timestamp>, + pub remix_count: u32, + pub like_count: u32, + pub point_incentive_total_half_points: u64, + pub point_incentive_claimed_points: u64, } impl __sdk::InModule for PuzzleWorkProfileRow { @@ -41,11 +48,14 @@ pub struct PuzzleWorkProfileRowCols { pub owner_user_id: __sdk::__query_builder::Col, pub source_session_id: __sdk::__query_builder::Col>, pub author_display_name: __sdk::__query_builder::Col, + pub work_title: __sdk::__query_builder::Col, + pub work_description: __sdk::__query_builder::Col, pub level_name: __sdk::__query_builder::Col, pub summary: __sdk::__query_builder::Col, pub theme_tags_json: __sdk::__query_builder::Col, pub cover_image_src: __sdk::__query_builder::Col>, pub cover_asset_id: __sdk::__query_builder::Col>, + pub levels_json: __sdk::__query_builder::Col, pub publication_status: __sdk::__query_builder::Col, pub play_count: __sdk::__query_builder::Col, @@ -54,6 +64,10 @@ pub struct PuzzleWorkProfileRowCols { pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, pub published_at: __sdk::__query_builder::Col>, + pub remix_count: __sdk::__query_builder::Col, + pub like_count: __sdk::__query_builder::Col, + pub point_incentive_total_half_points: __sdk::__query_builder::Col, + pub point_incentive_claimed_points: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for PuzzleWorkProfileRow { @@ -68,11 +82,14 @@ impl __sdk::__query_builder::HasCols for PuzzleWorkProfileRow { table_name, "author_display_name", ), + work_title: __sdk::__query_builder::Col::new(table_name, "work_title"), + work_description: __sdk::__query_builder::Col::new(table_name, "work_description"), level_name: __sdk::__query_builder::Col::new(table_name, "level_name"), summary: __sdk::__query_builder::Col::new(table_name, "summary"), theme_tags_json: __sdk::__query_builder::Col::new(table_name, "theme_tags_json"), cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), cover_asset_id: __sdk::__query_builder::Col::new(table_name, "cover_asset_id"), + levels_json: __sdk::__query_builder::Col::new(table_name, "levels_json"), publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), anchor_pack_json: __sdk::__query_builder::Col::new(table_name, "anchor_pack_json"), @@ -80,6 +97,16 @@ impl __sdk::__query_builder::HasCols for PuzzleWorkProfileRow { created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), + remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"), + like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), + point_incentive_total_half_points: __sdk::__query_builder::Col::new( + table_name, + "point_incentive_total_half_points", + ), + point_incentive_claimed_points: __sdk::__query_builder::Col::new( + table_name, + "point_incentive_claimed_points", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_remix_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_remix_input_type.rs new file mode 100644 index 00000000..c66a5fef --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_remix_input_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleWorkRemixInput { + pub source_profile_id: String, + pub target_owner_user_id: String, + pub target_session_id: String, + pub target_profile_id: String, + pub target_work_id: String, + pub author_display_name: String, + pub welcome_message_id: String, + pub remixed_at_micros: i64, +} + +impl __sdk::InModule for PuzzleWorkRemixInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_upsert_input_type.rs index 809ce64d..cff80418 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_upsert_input_type.rs @@ -9,11 +9,14 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub struct PuzzleWorkUpsertInput { pub profile_id: String, pub owner_user_id: String, + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, pub cover_image_src: Option, pub cover_asset_id: Option, + pub levels_json: Option, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_like_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_like_procedure.rs new file mode 100644 index 00000000..536aa47d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_like_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::big_fish_work_like_record_input_type::BigFishWorkLikeRecordInput; +use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RecordBigFishLikeArgs { + pub input: BigFishWorkLikeRecordInput, +} + +impl __sdk::InModule for RecordBigFishLikeArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_big_fish_like`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_big_fish_like { + fn record_big_fish_like(&self, input: BigFishWorkLikeRecordInput) { + self.record_big_fish_like_then(input, |_, _| {}); + } + + fn record_big_fish_like_then( + &self, + input: BigFishWorkLikeRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl record_big_fish_like for super::RemoteProcedures { + fn record_big_fish_like_then( + &self, + input: BigFishWorkLikeRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, BigFishWorksProcedureResult>( + "record_big_fish_like", + RecordBigFishLikeArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_custom_world_profile_like_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_custom_world_profile_like_procedure.rs new file mode 100644 index 00000000..1cd0aaad --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_custom_world_profile_like_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; +use super::custom_world_profile_like_record_input_type::CustomWorldProfileLikeRecordInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RecordCustomWorldProfileLikeArgs { + pub input: CustomWorldProfileLikeRecordInput, +} + +impl __sdk::InModule for RecordCustomWorldProfileLikeArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_custom_world_profile_like`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_custom_world_profile_like { + fn record_custom_world_profile_like(&self, input: CustomWorldProfileLikeRecordInput) { + self.record_custom_world_profile_like_then(input, |_, _| {}); + } + + fn record_custom_world_profile_like_then( + &self, + input: CustomWorldProfileLikeRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl record_custom_world_profile_like for super::RemoteProcedures { + fn record_custom_world_profile_like_then( + &self, + input: CustomWorldProfileLikeRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>( + "record_custom_world_profile_like", + RecordCustomWorldProfileLikeArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_custom_world_profile_play_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_custom_world_profile_play_procedure.rs new file mode 100644 index 00000000..2534ee30 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_custom_world_profile_play_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; +use super::custom_world_profile_play_record_input_type::CustomWorldProfilePlayRecordInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RecordCustomWorldProfilePlayArgs { + pub input: CustomWorldProfilePlayRecordInput, +} + +impl __sdk::InModule for RecordCustomWorldProfilePlayArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_custom_world_profile_play`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_custom_world_profile_play { + fn record_custom_world_profile_play(&self, input: CustomWorldProfilePlayRecordInput) { + self.record_custom_world_profile_play_then(input, |_, _| {}); + } + + fn record_custom_world_profile_play_then( + &self, + input: CustomWorldProfilePlayRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl record_custom_world_profile_play for super::RemoteProcedures { + fn record_custom_world_profile_play_then( + &self, + input: CustomWorldProfilePlayRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>( + "record_custom_world_profile_play", + RecordCustomWorldProfilePlayArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_puzzle_work_like_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_puzzle_work_like_procedure.rs new file mode 100644 index 00000000..fa55cf09 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_puzzle_work_like_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_work_like_record_input_type::PuzzleWorkLikeRecordInput; +use super::puzzle_work_procedure_result_type::PuzzleWorkProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RecordPuzzleWorkLikeArgs { + pub input: PuzzleWorkLikeRecordInput, +} + +impl __sdk::InModule for RecordPuzzleWorkLikeArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_puzzle_work_like`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_puzzle_work_like { + fn record_puzzle_work_like(&self, input: PuzzleWorkLikeRecordInput) { + self.record_puzzle_work_like_then(input, |_, _| {}); + } + + fn record_puzzle_work_like_then( + &self, + input: PuzzleWorkLikeRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl record_puzzle_work_like for super::RemoteProcedures { + fn record_puzzle_work_like_then( + &self, + input: PuzzleWorkLikeRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleWorkProcedureResult>( + "record_puzzle_work_like", + RecordPuzzleWorkLikeArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/remix_big_fish_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/remix_big_fish_work_procedure.rs new file mode 100644 index 00000000..ff7d1486 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/remix_big_fish_work_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::big_fish_session_procedure_result_type::BigFishSessionProcedureResult; +use super::big_fish_work_remix_input_type::BigFishWorkRemixInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RemixBigFishWorkArgs { + pub input: BigFishWorkRemixInput, +} + +impl __sdk::InModule for RemixBigFishWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `remix_big_fish_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait remix_big_fish_work { + fn remix_big_fish_work(&self, input: BigFishWorkRemixInput) { + self.remix_big_fish_work_then(input, |_, _| {}); + } + + fn remix_big_fish_work_then( + &self, + input: BigFishWorkRemixInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl remix_big_fish_work for super::RemoteProcedures { + fn remix_big_fish_work_then( + &self, + input: BigFishWorkRemixInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, BigFishSessionProcedureResult>( + "remix_big_fish_work", + RemixBigFishWorkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/remix_custom_world_profile_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/remix_custom_world_profile_procedure.rs new file mode 100644 index 00000000..8cd29b12 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/remix_custom_world_profile_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; +use super::custom_world_profile_remix_input_type::CustomWorldProfileRemixInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RemixCustomWorldProfileArgs { + pub input: CustomWorldProfileRemixInput, +} + +impl __sdk::InModule for RemixCustomWorldProfileArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `remix_custom_world_profile`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait remix_custom_world_profile { + fn remix_custom_world_profile(&self, input: CustomWorldProfileRemixInput) { + self.remix_custom_world_profile_then(input, |_, _| {}); + } + + fn remix_custom_world_profile_then( + &self, + input: CustomWorldProfileRemixInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl remix_custom_world_profile for super::RemoteProcedures { + fn remix_custom_world_profile_then( + &self, + input: CustomWorldProfileRemixInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>( + "remix_custom_world_profile", + RemixCustomWorldProfileArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/remix_puzzle_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/remix_puzzle_work_procedure.rs new file mode 100644 index 00000000..d2548069 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/remix_puzzle_work_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_agent_session_procedure_result_type::PuzzleAgentSessionProcedureResult; +use super::puzzle_work_remix_input_type::PuzzleWorkRemixInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RemixPuzzleWorkArgs { + pub input: PuzzleWorkRemixInput, +} + +impl __sdk::InModule for RemixPuzzleWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `remix_puzzle_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait remix_puzzle_work { + fn remix_puzzle_work(&self, input: PuzzleWorkRemixInput) { + self.remix_puzzle_work_then(input, |_, _| {}); + } + + fn remix_puzzle_work_then( + &self, + input: PuzzleWorkRemixInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl remix_puzzle_work for super::RemoteProcedures { + fn remix_puzzle_work_then( + &self, + input: PuzzleWorkRemixInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>( + "remix_puzzle_work", + RemixPuzzleWorkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/restart_match_3_d_run_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/restart_match_3_d_run_procedure.rs new file mode 100644 index 00000000..76c74037 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/restart_match_3_d_run_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_run_procedure_result_type::Match3DRunProcedureResult; +use super::match_3_d_run_restart_input_type::Match3DRunRestartInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RestartMatch3DRunArgs { + pub input: Match3DRunRestartInput, +} + +impl __sdk::InModule for RestartMatch3DRunArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `restart_match_3_d_run`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait restart_match_3_d_run { + fn restart_match_3_d_run(&self, input: Match3DRunRestartInput) { + self.restart_match_3_d_run_then(input, |_, _| {}); + } + + fn restart_match_3_d_run_then( + &self, + input: Match3DRunRestartInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl restart_match_3_d_run for super::RemoteProcedures { + fn restart_match_3_d_run_then( + &self, + input: Match3DRunRestartInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DRunProcedureResult>( + "restart_match_3_d_run", + RestartMatch3DRunArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs new file mode 100644 index 00000000..70f54300 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileInviteCodeAdminProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileInviteCodeAdminProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs new file mode 100644 index 00000000..77daf412 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileInviteCodeAdminUpsertInput { + pub admin_user_id: String, + pub invite_code: String, + pub metadata_json: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileInviteCodeAdminUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs new file mode 100644 index 00000000..36ea09ee --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileInviteCodeSnapshot { + pub user_id: String, + pub invite_code: String, + pub metadata_json: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileInviteCodeSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs index 21635ff9..cb081740 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs @@ -10,6 +10,8 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub enum RuntimeProfileWalletLedgerSourceType { SnapshotSync, + NewUserRegistrationReward, + InviteInviterReward, InviteInviteeReward, @@ -21,6 +23,8 @@ pub enum RuntimeProfileWalletLedgerSourceType { AssetOperationRefund, RedeemCodeReward, + + PuzzleAuthorIncentiveClaim, } impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_referral_invite_center_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_referral_invite_center_snapshot_type.rs index ca50d69f..c85f351e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_referral_invite_center_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_referral_invite_center_snapshot_type.rs @@ -4,6 +4,8 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_referral_invited_user_snapshot_type::RuntimeReferralInvitedUserSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct RuntimeReferralInviteCenterSnapshot { @@ -15,6 +17,7 @@ pub struct RuntimeReferralInviteCenterSnapshot { pub today_inviter_reward_count: u32, pub today_inviter_reward_remaining: u32, pub reward_points: u64, + pub invited_users: Vec, pub has_redeemed_code: bool, pub bound_inviter_user_id: Option, pub bound_at_micros: Option, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_referral_invited_user_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_referral_invited_user_snapshot_type.rs new file mode 100644 index 00000000..307801af --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_referral_invited_user_snapshot_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeReferralInvitedUserSnapshot { + pub user_id: String, + pub display_name: String, + pub avatar_url: Option, + pub bound_at_micros: i64, +} + +impl __sdk::InModule for RuntimeReferralInvitedUserSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_form_draft_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_form_draft_procedure.rs new file mode 100644 index 00000000..13aff9a3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_form_draft_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_agent_session_procedure_result_type::PuzzleAgentSessionProcedureResult; +use super::puzzle_form_draft_save_input_type::PuzzleFormDraftSaveInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SavePuzzleFormDraftArgs { + pub input: PuzzleFormDraftSaveInput, +} + +impl __sdk::InModule for SavePuzzleFormDraftArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `save_puzzle_form_draft`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait save_puzzle_form_draft { + fn save_puzzle_form_draft(&self, input: PuzzleFormDraftSaveInput) { + self.save_puzzle_form_draft_then(input, |_, _| {}); + } + + fn save_puzzle_form_draft_then( + &self, + input: PuzzleFormDraftSaveInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl save_puzzle_form_draft for super::RemoteProcedures { + fn save_puzzle_form_draft_then( + &self, + input: PuzzleFormDraftSaveInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>( + "save_puzzle_form_draft", + SavePuzzleFormDraftArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/start_match_3_d_run_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/start_match_3_d_run_procedure.rs new file mode 100644 index 00000000..c0a8c8a9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/start_match_3_d_run_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_run_procedure_result_type::Match3DRunProcedureResult; +use super::match_3_d_run_start_input_type::Match3DRunStartInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct StartMatch3DRunArgs { + pub input: Match3DRunStartInput, +} + +impl __sdk::InModule for StartMatch3DRunArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `start_match_3_d_run`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait start_match_3_d_run { + fn start_match_3_d_run(&self, input: Match3DRunStartInput) { + self.start_match_3_d_run_then(input, |_, _| {}); + } + + fn start_match_3_d_run_then( + &self, + input: Match3DRunStartInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl start_match_3_d_run for super::RemoteProcedures { + fn start_match_3_d_run_then( + &self, + input: Match3DRunStartInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DRunProcedureResult>( + "start_match_3_d_run", + StartMatch3DRunArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/stop_match_3_d_run_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/stop_match_3_d_run_procedure.rs new file mode 100644 index 00000000..630e7a98 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/stop_match_3_d_run_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_run_procedure_result_type::Match3DRunProcedureResult; +use super::match_3_d_run_stop_input_type::Match3DRunStopInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct StopMatch3DRunArgs { + pub input: Match3DRunStopInput, +} + +impl __sdk::InModule for StopMatch3DRunArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `stop_match_3_d_run`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait stop_match_3_d_run { + fn stop_match_3_d_run(&self, input: Match3DRunStopInput) { + self.stop_match_3_d_run_then(input, |_, _| {}); + } + + fn stop_match_3_d_run_then( + &self, + input: Match3DRunStopInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl stop_match_3_d_run for super::RemoteProcedures { + fn stop_match_3_d_run_then( + &self, + input: Match3DRunStopInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DRunProcedureResult>( + "stop_match_3_d_run", + StopMatch3DRunArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/submit_match_3_d_agent_message_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/submit_match_3_d_agent_message_procedure.rs new file mode 100644 index 00000000..c323ecda --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/submit_match_3_d_agent_message_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_agent_message_submit_input_type::Match3DAgentMessageSubmitInput; +use super::match_3_d_agent_session_procedure_result_type::Match3DAgentSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SubmitMatch3DAgentMessageArgs { + pub input: Match3DAgentMessageSubmitInput, +} + +impl __sdk::InModule for SubmitMatch3DAgentMessageArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `submit_match_3_d_agent_message`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait submit_match_3_d_agent_message { + fn submit_match_3_d_agent_message(&self, input: Match3DAgentMessageSubmitInput) { + self.submit_match_3_d_agent_message_then(input, |_, _| {}); + } + + fn submit_match_3_d_agent_message_then( + &self, + input: Match3DAgentMessageSubmitInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl submit_match_3_d_agent_message for super::RemoteProcedures { + fn submit_match_3_d_agent_message_then( + &self, + input: Match3DAgentMessageSubmitInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DAgentSessionProcedureResult>( + "submit_match_3_d_agent_message", + SubmitMatch3DAgentMessageArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_match_3_d_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_match_3_d_work_procedure.rs new file mode 100644 index 00000000..cdc42782 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_match_3_d_work_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_work_procedure_result_type::Match3DWorkProcedureResult; +use super::match_3_d_work_update_input_type::Match3DWorkUpdateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpdateMatch3DWorkArgs { + pub input: Match3DWorkUpdateInput, +} + +impl __sdk::InModule for UpdateMatch3DWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `update_match_3_d_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait update_match_3_d_work { + fn update_match_3_d_work(&self, input: Match3DWorkUpdateInput) { + self.update_match_3_d_work_then(input, |_, _| {}); + } + + fn update_match_3_d_work_then( + &self, + input: Match3DWorkUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl update_match_3_d_work for super::RemoteProcedures { + fn update_match_3_d_work_then( + &self, + input: Match3DWorkUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Match3DWorkProcedureResult>( + "update_match_3_d_work", + UpdateMatch3DWorkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_run_pause_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_run_pause_procedure.rs new file mode 100644 index 00000000..3388380c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_run_pause_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_run_pause_input_type::PuzzleRunPauseInput; +use super::puzzle_run_procedure_result_type::PuzzleRunProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpdatePuzzleRunPauseArgs { + pub input: PuzzleRunPauseInput, +} + +impl __sdk::InModule for UpdatePuzzleRunPauseArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `update_puzzle_run_pause`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait update_puzzle_run_pause { + fn update_puzzle_run_pause(&self, input: PuzzleRunPauseInput) { + self.update_puzzle_run_pause_then(input, |_, _| {}); + } + + fn update_puzzle_run_pause_then( + &self, + input: PuzzleRunPauseInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl update_puzzle_run_pause for super::RemoteProcedures { + fn update_puzzle_run_pause_then( + &self, + input: PuzzleRunPauseInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleRunProcedureResult>( + "update_puzzle_run_pause", + UpdatePuzzleRunPauseArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/use_puzzle_runtime_prop_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/use_puzzle_runtime_prop_procedure.rs new file mode 100644 index 00000000..1e2dbe13 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/use_puzzle_runtime_prop_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_run_procedure_result_type::PuzzleRunProcedureResult; +use super::puzzle_run_prop_input_type::PuzzleRunPropInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UsePuzzleRuntimePropArgs { + pub input: PuzzleRunPropInput, +} + +impl __sdk::InModule for UsePuzzleRuntimePropArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `use_puzzle_runtime_prop`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait use_puzzle_runtime_prop { + fn use_puzzle_runtime_prop(&self, input: PuzzleRunPropInput) { + self.use_puzzle_runtime_prop_then(input, |_, _| {}); + } + + fn use_puzzle_runtime_prop_then( + &self, + input: PuzzleRunPropInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl use_puzzle_runtime_prop for super::RemoteProcedures { + fn use_puzzle_runtime_prop_then( + &self, + input: PuzzleRunPropInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleRunProcedureResult>( + "use_puzzle_runtime_prop", + UsePuzzleRuntimePropArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs index 87b8e5f3..50dd5520 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs @@ -11,6 +11,7 @@ pub struct UserAccount { pub public_user_code: String, pub username: String, pub display_name: String, + pub avatar_url: Option, pub phone_number_masked: Option, pub phone_number_e_164: Option, pub login_method: String, @@ -33,6 +34,7 @@ pub struct UserAccountCols { pub public_user_code: __sdk::__query_builder::Col, pub username: __sdk::__query_builder::Col, pub display_name: __sdk::__query_builder::Col, + pub avatar_url: __sdk::__query_builder::Col>, pub phone_number_masked: __sdk::__query_builder::Col>, pub phone_number_e_164: __sdk::__query_builder::Col>, pub login_method: __sdk::__query_builder::Col, @@ -51,6 +53,7 @@ impl __sdk::__query_builder::HasCols for UserAccount { public_user_code: __sdk::__query_builder::Col::new(table_name, "public_user_code"), username: __sdk::__query_builder::Col::new(table_name, "username"), display_name: __sdk::__query_builder::Col::new(table_name, "display_name"), + avatar_url: __sdk::__query_builder::Col::new(table_name, "avatar_url"), phone_number_masked: __sdk::__query_builder::Col::new( table_name, "phone_number_masked", diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index 506e874a..1fb2e6bf 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -1,6 +1,9 @@ use super::*; use crate::mapper::*; +use crate::module_bindings::claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive; use crate::module_bindings::delete_puzzle_work_procedure::delete_puzzle_work; +use crate::module_bindings::record_puzzle_work_like_procedure::record_puzzle_work_like; +use crate::module_bindings::remix_puzzle_work_procedure::remix_puzzle_work; impl SpacetimeClient { pub async fn create_puzzle_agent_session( @@ -54,6 +57,31 @@ impl SpacetimeClient { .await } + pub async fn save_puzzle_form_draft( + &self, + input: PuzzleFormDraftSaveRecordInput, + ) -> Result { + let procedure_input = PuzzleFormDraftSaveInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + seed_text: input.seed_text, + saved_at_micros: input.saved_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().save_puzzle_form_draft_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn submit_puzzle_agent_message( &self, input: PuzzleAgentMessageSubmitRecordInput, @@ -142,6 +170,8 @@ impl SpacetimeClient { let procedure_input = PuzzleGeneratedImagesSaveInput { session_id: input.session_id, owner_user_id: input.owner_user_id, + level_id: input.level_id, + levels_json: input.levels_json, candidates_json: input.candidates_json, saved_at_micros: input.saved_at_micros, }; @@ -167,6 +197,7 @@ impl SpacetimeClient { let procedure_input = PuzzleSelectCoverImageInput { session_id: input.session_id, owner_user_id: input.owner_user_id, + level_id: input.level_id, candidate_id: input.candidate_id, selected_at_micros: input.selected_at_micros, }; @@ -195,9 +226,12 @@ impl SpacetimeClient { work_id: input.work_id, profile_id: input.profile_id, author_display_name: input.author_display_name, + work_title: input.work_title, + work_description: input.work_description, level_name: input.level_name, summary: input.summary, theme_tags: input.theme_tags, + levels_json: input.levels_json, published_at_micros: input.published_at_micros, }; @@ -260,11 +294,14 @@ impl SpacetimeClient { let procedure_input = PuzzleWorkUpsertInput { profile_id: input.profile_id, owner_user_id: input.owner_user_id, + work_title: input.work_title, + work_description: input.work_description, level_name: input.level_name, summary: input.summary, theme_tags: input.theme_tags, cover_image_src: input.cover_image_src, cover_asset_id: input.cover_asset_id, + levels_json: input.levels_json, updated_at_micros: input.updated_at_micros, }; @@ -304,6 +341,29 @@ impl SpacetimeClient { .await } + pub async fn claim_puzzle_work_point_incentive( + &self, + input: PuzzleWorkPointIncentiveClaimRecordInput, + ) -> Result { + let procedure_input = PuzzleWorkPointIncentiveClaimInput { + profile_id: input.profile_id, + owner_user_id: input.owner_user_id, + claimed_at_micros: input.claimed_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .claim_puzzle_work_point_incentive_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_work_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn list_puzzle_gallery( &self, ) -> Result, SpacetimeClientError> { @@ -340,6 +400,58 @@ impl SpacetimeClient { .await } + pub async fn record_puzzle_work_like( + &self, + input: PuzzleWorkLikeReportRecordInput, + ) -> Result { + let procedure_input = PuzzleWorkLikeRecordInput { + profile_id: input.profile_id, + user_id: input.user_id, + liked_at_micros: input.liked_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().record_puzzle_work_like_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn remix_puzzle_work( + &self, + input: PuzzleWorkRemixRecordInput, + ) -> Result { + let procedure_input = PuzzleWorkRemixInput { + source_profile_id: input.source_profile_id, + target_owner_user_id: input.target_owner_user_id, + target_session_id: input.target_session_id, + target_profile_id: input.target_profile_id, + target_work_id: input.target_work_id, + author_display_name: input.author_display_name, + welcome_message_id: input.welcome_message_id, + remixed_at_micros: input.remixed_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .remix_puzzle_work_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn start_puzzle_run( &self, input: PuzzleRunStartRecordInput, @@ -348,6 +460,7 @@ impl SpacetimeClient { run_id: input.run_id, owner_user_id: input.owner_user_id, profile_id: input.profile_id, + level_id: input.level_id, started_at_micros: input.started_at_micros, }; @@ -463,6 +576,57 @@ impl SpacetimeClient { .await } + pub async fn update_puzzle_run_pause( + &self, + input: PuzzleRunPauseRecordInput, + ) -> Result { + let procedure_input = PuzzleRunPauseInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + paused: input.paused, + updated_at_micros: input.updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().update_puzzle_run_pause_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn use_puzzle_runtime_prop( + &self, + input: PuzzleRunPropRecordInput, + ) -> Result { + let procedure_input = PuzzleRunPropInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + prop_kind: input.prop_kind, + used_at_micros: input.used_at_micros, + spent_points: input.spent_points, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().use_puzzle_runtime_prop_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn submit_puzzle_leaderboard_entry( &self, input: PuzzleLeaderboardSubmitRecordInput, diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 6cdc454f..d0de8062 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -89,6 +89,30 @@ impl SpacetimeClient { .await } + pub async fn grant_new_user_registration_wallet_reward( + &self, + user_id: String, + ) -> Result { + let procedure_input = build_runtime_profile_dashboard_get_input(user_id) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .grant_new_user_registration_wallet_reward_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_wallet_adjustment_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn consume_profile_wallet_points( &self, user_id: String, @@ -346,6 +370,35 @@ impl SpacetimeClient { .await } + pub async fn admin_upsert_profile_invite_code( + &self, + admin_user_id: String, + invite_code: String, + metadata_json: String, + updated_at_micros: i64, + ) -> Result { + let procedure_input = build_runtime_profile_invite_code_admin_upsert_input( + admin_user_id, + invite_code, + metadata_json, + updated_at_micros, + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .admin_upsert_profile_invite_code_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_invite_code_admin_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn get_profile_play_stats( &self, user_id: String, diff --git a/server-rs/crates/spacetime-module/Cargo.toml b/server-rs/crates/spacetime-module/Cargo.toml index 62749ac7..2ae5c223 100644 --- a/server-rs/crates/spacetime-module/Cargo.toml +++ b/server-rs/crates/spacetime-module/Cargo.toml @@ -18,6 +18,7 @@ module-big-fish = { path = "../module-big-fish", default-features = false, featu module-combat = { path = "../module-combat", default-features = false, features = ["spacetime-types"] } module-inventory = { path = "../module-inventory", default-features = false, features = ["spacetime-types"] } module-custom-world = { path = "../module-custom-world", default-features = false, features = ["spacetime-types"] } +module-match3d = { path = "../module-match3d", default-features = false } module-npc = { path = "../module-npc", default-features = false, features = ["spacetime-types"] } module-puzzle = { path = "../module-puzzle", default-features = false, features = ["spacetime-types"] } module-progression = { path = "../module-progression", default-features = false, features = ["spacetime-types"] } diff --git a/server-rs/crates/spacetime-module/src/auth/mapper.rs b/server-rs/crates/spacetime-module/src/auth/mapper.rs index 64e7e5f6..1bc4342f 100644 --- a/server-rs/crates/spacetime-module/src/auth/mapper.rs +++ b/server-rs/crates/spacetime-module/src/auth/mapper.rs @@ -50,6 +50,8 @@ pub(super) struct AuthUserSnapshot { pub(super) public_user_code: String, pub(super) username: String, pub(super) display_name: String, + #[serde(default)] + pub(super) avatar_url: Option, pub(super) phone_number_masked: Option, pub(super) login_method: String, pub(super) binding_status: String, diff --git a/server-rs/crates/spacetime-module/src/auth/procedures.rs b/server-rs/crates/spacetime-module/src/auth/procedures.rs index 3150680f..d7336c27 100644 --- a/server-rs/crates/spacetime-module/src/auth/procedures.rs +++ b/server-rs/crates/spacetime-module/src/auth/procedures.rs @@ -201,6 +201,7 @@ fn import_auth_store_snapshot_tx( public_user_code: user.public_user_code, username: user.username, display_name: user.display_name, + avatar_url: user.avatar_url, phone_number_masked: user.phone_number_masked, phone_number_e164: stored_user.phone_number.clone(), login_method: user.login_method, @@ -332,6 +333,7 @@ fn export_auth_store_snapshot_from_tables_tx( public_user_code: user.public_user_code, username: user.username.clone(), display_name: user.display_name, + avatar_url: user.avatar_url, phone_number_masked: user.phone_number_masked, login_method: user.login_method, binding_status: user.binding_status, diff --git a/server-rs/crates/spacetime-module/src/auth/tables.rs b/server-rs/crates/spacetime-module/src/auth/tables.rs index 3cd84fb4..db0ee321 100644 --- a/server-rs/crates/spacetime-module/src/auth/tables.rs +++ b/server-rs/crates/spacetime-module/src/auth/tables.rs @@ -19,6 +19,7 @@ pub struct UserAccount { pub(crate) public_user_code: String, pub(crate) username: String, pub(crate) display_name: String, + pub(crate) avatar_url: Option, pub(crate) phone_number_masked: Option, pub(crate) phone_number_e164: Option, pub(crate) login_method: String, diff --git a/server-rs/crates/spacetime-module/src/big_fish/assets.rs b/server-rs/crates/spacetime-module/src/big_fish/assets.rs index b99e984c..2f0f1fa4 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/assets.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/assets.rs @@ -124,6 +124,9 @@ pub(crate) fn generate_big_fish_asset_tx( last_assistant_reply: Some(reply.clone()), publish_ready: readiness.readiness.publish_ready, play_count: session.play_count, + remix_count: session.remix_count, + like_count: session.like_count, + published_at: session.published_at, created_at: session.created_at, updated_at, }; @@ -192,6 +195,9 @@ pub(crate) fn publish_big_fish_game_tx( last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()), publish_ready: true, play_count: session.play_count, + remix_count: session.remix_count, + like_count: session.like_count, + published_at: Some(published_at), created_at: session.created_at, updated_at: published_at, }; diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 05b04e22..b7815e0a 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -2,7 +2,9 @@ use crate::big_fish::tables::{ big_fish_agent_message, big_fish_creation_session, big_fish_runtime_run, }; use crate::runtime::{ - ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work, + ProfilePlayedWorkUpsertInput, PublicWorkLikeRecordInput, PublicWorkPlayRecordInput, + add_profile_observed_play_time, count_recent_public_work_plays, record_public_work_like, + record_public_work_play, upsert_profile_played_work, }; use crate::*; use module_big_fish::{EvaluateBigFishPublishReadinessCommand, evaluate_publish_readiness}; @@ -125,6 +127,51 @@ pub fn record_big_fish_play( } } +#[spacetimedb::procedure] +pub fn record_big_fish_like( + ctx: &mut ProcedureContext, + input: BigFishWorkLikeRecordInput, +) -> BigFishWorksProcedureResult { + match ctx.try_with_tx(|tx| record_big_fish_like_tx(tx, input.clone())) { + Ok(items) => match serde_json::to_string(&items) { + Ok(items_json) => BigFishWorksProcedureResult { + ok: true, + items_json: Some(items_json), + error_message: None, + }, + Err(error) => BigFishWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(error.to_string()), + }, + }, + Err(message) => BigFishWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn remix_big_fish_work( + ctx: &mut ProcedureContext, + input: BigFishWorkRemixInput, +) -> BigFishSessionProcedureResult { + match ctx.try_with_tx(|tx| remix_big_fish_work_tx(tx, input.clone())) { + Ok(session) => BigFishSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + }, + Err(message) => BigFishSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn submit_big_fish_message( ctx: &mut ProcedureContext, @@ -227,6 +274,9 @@ pub(crate) fn create_big_fish_session_tx( last_assistant_reply: Some(input.welcome_message_text.clone()), publish_ready: false, play_count: 0, + remix_count: 0, + like_count: 0, + published_at: None, created_at, updated_at: created_at, }); @@ -269,6 +319,7 @@ pub(crate) fn list_big_fish_works_tx( input: BigFishWorksListInput, ) -> Result, String> { validate_works_list_input(&input).map_err(|error| error.to_string())?; + let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); let mut items = ctx .db @@ -281,7 +332,7 @@ pub(crate) fn list_big_fish_works_tx( row.owner_user_id == input.owner_user_id && should_include_big_fish_work(ctx, row) }) - .map(|row| build_big_fish_work_summary(ctx, &row)) + .map(|row| build_big_fish_work_summary(ctx, &row, now_micros)) .collect::, _>>()?; items.sort_by(|left, right| { @@ -426,6 +477,9 @@ pub(crate) fn submit_big_fish_message_tx( last_assistant_reply: session.last_assistant_reply.clone(), publish_ready: session.publish_ready, play_count: session.play_count, + remix_count: session.remix_count, + like_count: session.like_count, + published_at: session.published_at, created_at: session.created_at, updated_at: submitted_at, }; @@ -473,6 +527,9 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx( last_assistant_reply: session.last_assistant_reply.clone(), publish_ready: session.publish_ready, play_count: session.play_count, + remix_count: session.remix_count, + like_count: session.like_count, + published_at: session.published_at, created_at: session.created_at, updated_at, }; @@ -528,6 +585,9 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx( last_assistant_reply: Some(assistant_reply_text), publish_ready: session.publish_ready, play_count: session.play_count, + remix_count: session.remix_count, + like_count: session.like_count, + published_at: session.published_at, created_at: session.created_at, updated_at, }; @@ -592,6 +652,9 @@ pub(crate) fn compile_big_fish_draft_tx( last_assistant_reply: Some(reply.clone()), publish_ready: readiness.readiness.publish_ready, play_count: session.play_count, + remix_count: session.remix_count, + like_count: session.like_count, + published_at: session.published_at, created_at: session.created_at, updated_at: compiled_at, }; @@ -667,6 +730,15 @@ pub(crate) fn record_big_fish_play_tx( input.elapsed_ms, input.played_at_micros, )?; + record_public_work_play( + ctx, + PublicWorkPlayRecordInput { + source_type: "big-fish".to_string(), + owner_user_id: session.owner_user_id.clone(), + profile_id: session.session_id.clone(), + played_at_micros: input.played_at_micros, + }, + )?; let next_session = BigFishCreationSession { session_id: session.session_id.clone(), owner_user_id: session.owner_user_id.clone(), @@ -681,16 +753,184 @@ pub(crate) fn record_big_fish_play_tx( publish_ready: session.publish_ready, // 中文注释:正式进入已发布作品时同时累加作品播放数,用户侧去重由 profile_played_world 保证。 play_count: session.play_count.saturating_add(1), + remix_count: session.remix_count, + like_count: session.like_count, + published_at: session.published_at, created_at: session.created_at, updated_at: played_at, }; replace_big_fish_session(ctx, &session, next_session); - list_big_fish_works_tx( + list_big_fish_works_tx(ctx, build_public_big_fish_gallery_list_input()) +} + +pub(crate) fn record_big_fish_like_tx( + ctx: &ReducerContext, + input: BigFishWorkLikeRecordInput, +) -> Result, String> { + let session_id = input.session_id.trim(); + let user_id = input.user_id.trim(); + if session_id.is_empty() || user_id.is_empty() { + return Err("big_fish like 参数不能为空".to_string()); + } + let session = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&session_id.to_string()) + .filter(|row| row.stage == BigFishCreationStage::Published) + .ok_or_else(|| "big_fish 已发布作品不存在,无法点赞".to_string())?; + let inserted_like = record_public_work_like( ctx, - BigFishWorksListInput { - owner_user_id: String::new(), - published_only: true, + PublicWorkLikeRecordInput { + source_type: "big-fish".to_string(), + owner_user_id: session.owner_user_id.clone(), + profile_id: session.session_id.clone(), + user_id: user_id.to_string(), + liked_at_micros: input.liked_at_micros, + }, + )?; + + if inserted_like { + let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros); + let next_session = BigFishCreationSession { + session_id: session.session_id.clone(), + owner_user_id: session.owner_user_id.clone(), + seed_text: session.seed_text.clone(), + current_turn: session.current_turn, + progress_percent: session.progress_percent, + stage: session.stage, + anchor_pack_json: session.anchor_pack_json.clone(), + draft_json: session.draft_json.clone(), + asset_coverage_json: session.asset_coverage_json.clone(), + last_assistant_reply: session.last_assistant_reply.clone(), + publish_ready: session.publish_ready, + play_count: session.play_count, + remix_count: session.remix_count, + like_count: session.like_count.saturating_add(1), + published_at: session.published_at, + created_at: session.created_at, + updated_at: liked_at, + }; + replace_big_fish_session(ctx, &session, next_session); + } + + list_big_fish_works_tx(ctx, build_public_big_fish_gallery_list_input()) +} + +fn remix_big_fish_work_tx( + ctx: &ReducerContext, + input: BigFishWorkRemixInput, +) -> Result { + let source_session_id = input.source_session_id.trim(); + let target_session_id = input.target_session_id.trim(); + let target_owner_user_id = input.target_owner_user_id.trim(); + let welcome_message_id = input.welcome_message_id.trim(); + if source_session_id.is_empty() + || target_session_id.is_empty() + || target_owner_user_id.is_empty() + || welcome_message_id.is_empty() + { + return Err("big_fish remix 参数不能为空".to_string()); + } + if ctx + .db + .big_fish_creation_session() + .session_id() + .find(&target_session_id.to_string()) + .is_some() + { + return Err("big_fish remix 目标 session 已存在".to_string()); + } + if ctx + .db + .big_fish_agent_message() + .message_id() + .find(&welcome_message_id.to_string()) + .is_some() + { + return Err("big_fish remix 消息已存在".to_string()); + } + + let source = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&source_session_id.to_string()) + .filter(|row| row.stage == BigFishCreationStage::Published) + .ok_or_else(|| "big_fish 已发布源作品不存在".to_string())?; + let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros); + let next_source = BigFishCreationSession { + session_id: source.session_id.clone(), + owner_user_id: source.owner_user_id.clone(), + seed_text: source.seed_text.clone(), + current_turn: source.current_turn, + progress_percent: source.progress_percent, + stage: source.stage, + anchor_pack_json: source.anchor_pack_json.clone(), + draft_json: source.draft_json.clone(), + asset_coverage_json: source.asset_coverage_json.clone(), + last_assistant_reply: source.last_assistant_reply.clone(), + publish_ready: source.publish_ready, + play_count: source.play_count, + remix_count: source.remix_count.saturating_add(1), + like_count: source.like_count, + published_at: source.published_at, + created_at: source.created_at, + updated_at: remixed_at, + }; + replace_big_fish_session(ctx, &source, next_source); + + let target_session = BigFishCreationSession { + session_id: target_session_id.to_string(), + owner_user_id: target_owner_user_id.to_string(), + seed_text: source.seed_text.clone(), + current_turn: 1, + progress_percent: 80, + stage: BigFishCreationStage::DraftReady, + anchor_pack_json: source.anchor_pack_json.clone(), + draft_json: source.draft_json.clone(), + asset_coverage_json: source.asset_coverage_json.clone(), + last_assistant_reply: Some("已从公开作品 Remix 出新的大鱼吃小鱼草稿。".to_string()), + publish_ready: source.publish_ready, + play_count: 0, + remix_count: 0, + like_count: 0, + published_at: None, + created_at: remixed_at, + updated_at: remixed_at, + }; + ctx.db.big_fish_creation_session().insert(target_session); + ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { + message_id: welcome_message_id.to_string(), + session_id: target_session_id.to_string(), + role: BigFishAgentMessageRole::Assistant, + kind: BigFishAgentMessageKind::Summary, + text: "已复制公开作品为你的草稿。".to_string(), + created_at: remixed_at, + }); + for slot in list_big_fish_asset_slots(ctx, &source.session_id) { + upsert_big_fish_asset_slot( + ctx, + BigFishAssetSlotSnapshot { + slot_id: slot.slot_id.replace(&source.session_id, target_session_id), + session_id: target_session_id.to_string(), + asset_kind: slot.asset_kind, + level: slot.level, + motion_key: slot.motion_key, + status: slot.status, + asset_url: slot.asset_url, + prompt_snapshot: slot.prompt_snapshot, + updated_at_micros: input.remixed_at_micros, + }, + ); + } + + get_big_fish_session_tx( + ctx, + BigFishSessionGetInput { + session_id: target_session_id.to_string(), + owner_user_id: target_owner_user_id.to_string(), }, ) } @@ -747,6 +987,7 @@ pub(crate) fn build_big_fish_session_snapshot( pub(crate) fn build_big_fish_work_summary( ctx: &ReducerContext, row: &BigFishCreationSession, + now_micros: i64, ) -> Result { let draft = row .draft_json @@ -809,9 +1050,29 @@ pub(crate) fn build_big_fish_work_summary( level_motion_ready_count: coverage.level_motion_ready_count, background_ready: coverage.background_ready, play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, + recent_play_count_7d: count_recent_public_work_plays( + ctx, + "big-fish", + &row.session_id, + now_micros, + ), + published_at_micros: row + .published_at + .or_else(|| (row.stage == BigFishCreationStage::Published).then_some(row.updated_at)) + .map(|value| value.to_micros_since_unix_epoch()), }) } +fn build_public_big_fish_gallery_list_input() -> BigFishWorksListInput { + BigFishWorksListInput { + // 中文注释:published_only 分支不会按 owner 过滤;非空占位用于兼容旧部署模块的前置校验。 + owner_user_id: PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID.to_string(), + published_only: true, + } +} + pub(crate) fn replace_big_fish_session( ctx: &ReducerContext, current: &BigFishCreationSession, @@ -846,6 +1107,13 @@ mod tests { last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()), publish_ready: false, play_count: 0, + remix_count: 0, + like_count: 0, + published_at: if stage == BigFishCreationStage::Published { + Some(Timestamp::from_micros_since_unix_epoch(1)) + } else { + None + }, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), } diff --git a/server-rs/crates/spacetime-module/src/big_fish/tables.rs b/server-rs/crates/spacetime-module/src/big_fish/tables.rs index 31183469..997371e8 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/tables.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/tables.rs @@ -17,9 +17,16 @@ pub struct BigFishCreationSession { pub(crate) asset_coverage_json: String, pub(crate) last_assistant_reply: Option, pub(crate) publish_ready: bool, - pub(crate) play_count: u32, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, + #[default(0)] + pub(crate) play_count: u32, + #[default(0)] + pub(crate) remix_count: u32, + #[default(0)] + pub(crate) like_count: u32, + #[default(None::)] + pub(crate) published_at: Option, } #[spacetimedb::table( diff --git a/server-rs/crates/spacetime-module/src/custom_world/mod.rs b/server-rs/crates/spacetime-module/src/custom_world/mod.rs index 7a2eee29..bfba055e 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -28,6 +28,13 @@ pub struct CustomWorldProfile { profile_payload_json: String, playable_npc_count: u32, landmark_count: u32, + // 公开消费计数随 profile 真相持久化,发布、编辑和取消发布都不能重置。 + #[default(0)] + play_count: u32, + #[default(0)] + remix_count: u32, + #[default(0)] + like_count: u32, author_display_name: String, published_at: Option, // 软删除后保留 profile 真相,供审计与幂等删除使用。 @@ -175,6 +182,13 @@ pub struct CustomWorldGalleryEntry { theme_mode: CustomWorldThemeMode, playable_npc_count: u32, landmark_count: u32, + // 画廊读模型直接同步互动计数,避免前端临时把评分或游玩数改名成点赞。 + #[default(0)] + play_count: u32, + #[default(0)] + remix_count: u32, + #[default(0)] + like_count: u32, published_at: Timestamp, updated_at: Timestamp, } @@ -979,6 +993,69 @@ pub fn get_custom_world_gallery_detail_by_code( } } +#[spacetimedb::procedure] +pub fn remix_custom_world_profile( + ctx: &mut ProcedureContext, + input: module_custom_world::CustomWorldProfileRemixInput, +) -> CustomWorldLibraryMutationResult { + match ctx.try_with_tx(|tx| remix_custom_world_profile_record(tx, input.clone())) { + Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { + ok: true, + entry: Some(entry), + gallery_entry, + error_message: None, + }, + Err(message) => CustomWorldLibraryMutationResult { + ok: false, + entry: None, + gallery_entry: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn record_custom_world_profile_play( + ctx: &mut ProcedureContext, + input: module_custom_world::CustomWorldProfilePlayRecordInput, +) -> CustomWorldLibraryMutationResult { + match ctx.try_with_tx(|tx| record_custom_world_profile_play_record(tx, input.clone())) { + Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { + ok: true, + entry: Some(entry), + gallery_entry: Some(gallery_entry), + error_message: None, + }, + Err(message) => CustomWorldLibraryMutationResult { + ok: false, + entry: None, + gallery_entry: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn record_custom_world_profile_like( + ctx: &mut ProcedureContext, + input: module_custom_world::CustomWorldProfileLikeRecordInput, +) -> CustomWorldLibraryMutationResult { + match ctx.try_with_tx(|tx| record_custom_world_profile_like_record(tx, input.clone())) { + Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { + ok: true, + entry: Some(entry), + gallery_entry: Some(gallery_entry), + error_message: None, + }, + Err(message) => CustomWorldLibraryMutationResult { + ok: false, + entry: None, + gallery_entry: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn list_custom_world_works( ctx: &mut ProcedureContext, @@ -1134,6 +1211,9 @@ fn upsert_custom_world_profile_record( profile_payload_json: input.profile_payload_json.clone(), playable_npc_count: input.playable_npc_count, landmark_count: input.landmark_count, + play_count: existing.play_count, + remix_count: existing.remix_count, + like_count: existing.like_count, author_display_name: input.author_display_name.clone(), published_at: existing.published_at, deleted_at: None, @@ -1156,6 +1236,9 @@ fn upsert_custom_world_profile_record( profile_payload_json: input.profile_payload_json.clone(), playable_npc_count: input.playable_npc_count, landmark_count: input.landmark_count, + play_count: 0, + remix_count: 0, + like_count: 0, author_display_name: input.author_display_name.clone(), published_at: None, deleted_at: None, @@ -1300,6 +1383,9 @@ fn publish_custom_world_profile_record( profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, + play_count: existing.play_count, + remix_count: existing.remix_count, + like_count: existing.like_count, author_display_name: input.author_display_name.clone(), published_at: Some(published_at), deleted_at: None, @@ -1363,6 +1449,9 @@ fn unpublish_custom_world_profile_record( profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, + play_count: existing.play_count, + remix_count: existing.remix_count, + like_count: existing.like_count, author_display_name: input.author_display_name.clone(), published_at: None, deleted_at: None, @@ -1422,6 +1511,9 @@ fn delete_custom_world_profile_record( profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, + play_count: existing.play_count, + remix_count: existing.remix_count, + like_count: existing.like_count, author_display_name: existing.author_display_name.clone(), published_at: None, deleted_at: Some(deleted_at), @@ -1461,7 +1553,7 @@ fn list_custom_world_gallery_snapshots( .db .custom_world_gallery_entry() .iter() - .map(|row| build_custom_world_gallery_entry_snapshot(&row)) + .map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row)) .collect::>(); entries.sort_by(|left, right| { @@ -1508,7 +1600,7 @@ fn get_custom_world_library_detail_record( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() - .map(build_custom_world_gallery_entry_snapshot), + .map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)), )) } @@ -1546,7 +1638,7 @@ fn get_custom_world_gallery_detail_record( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() - .map(build_custom_world_gallery_entry_snapshot), + .map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)), )) } @@ -1588,7 +1680,273 @@ fn get_custom_world_gallery_detail_record_by_code( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() - .map(build_custom_world_gallery_entry_snapshot), + .map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)), + )) +} + +fn remix_custom_world_profile_record( + ctx: &ReducerContext, + input: module_custom_world::CustomWorldProfileRemixInput, +) -> Result< + ( + CustomWorldProfileSnapshot, + Option, + ), + String, +> { + let source_owner_user_id = input.source_owner_user_id.trim(); + let source_profile_id = input.source_profile_id.trim(); + let target_owner_user_id = input.target_owner_user_id.trim(); + let target_profile_id = input.target_profile_id.trim(); + if source_owner_user_id.is_empty() + || source_profile_id.is_empty() + || target_owner_user_id.is_empty() + || target_profile_id.is_empty() + { + return Err("custom_world remix 参数不能为空".to_string()); + } + if input.author_display_name.trim().is_empty() { + return Err("custom_world remix 作者名不能为空".to_string()); + } + + let source = ctx + .db + .custom_world_profile() + .profile_id() + .find(&source_profile_id.to_string()) + .filter(|row| row.owner_user_id == source_owner_user_id) + .filter(|row| { + row.publication_status == CustomWorldPublicationStatus::Published + && row.deleted_at.is_none() + && row.published_at.is_some() + }) + .ok_or_else(|| "custom_world 已发布源作品不存在,无法改编".to_string())?; + let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros); + + ctx.db + .custom_world_profile() + .profile_id() + .delete(&source.profile_id); + let next_source = CustomWorldProfile { + profile_id: source.profile_id.clone(), + owner_user_id: source.owner_user_id.clone(), + public_work_code: source.public_work_code.clone(), + author_public_user_code: source.author_public_user_code.clone(), + source_agent_session_id: source.source_agent_session_id.clone(), + publication_status: source.publication_status, + world_name: source.world_name.clone(), + subtitle: source.subtitle.clone(), + summary_text: source.summary_text.clone(), + theme_mode: source.theme_mode, + cover_image_src: source.cover_image_src.clone(), + profile_payload_json: source.profile_payload_json.clone(), + playable_npc_count: source.playable_npc_count, + landmark_count: source.landmark_count, + play_count: source.play_count, + remix_count: source.remix_count.saturating_add(1), + like_count: source.like_count, + author_display_name: source.author_display_name.clone(), + published_at: source.published_at, + deleted_at: source.deleted_at, + created_at: source.created_at, + updated_at: remixed_at, + }; + let updated_source = ctx.db.custom_world_profile().insert(next_source); + let source_gallery = sync_custom_world_gallery_entry_from_profile(ctx, &updated_source)?; + + // 改编生成目标用户草稿:复制内容,不复制源作品热度。 + let draft = CustomWorldProfile { + profile_id: target_profile_id.to_string(), + owner_user_id: target_owner_user_id.to_string(), + public_work_code: None, + author_public_user_code: None, + source_agent_session_id: None, + publication_status: CustomWorldPublicationStatus::Draft, + world_name: source.world_name.clone(), + subtitle: source.subtitle.clone(), + summary_text: source.summary_text.clone(), + theme_mode: source.theme_mode, + cover_image_src: source.cover_image_src.clone(), + profile_payload_json: source.profile_payload_json.clone(), + playable_npc_count: source.playable_npc_count, + landmark_count: source.landmark_count, + play_count: 0, + remix_count: 0, + like_count: 0, + author_display_name: input.author_display_name.trim().to_string(), + published_at: None, + deleted_at: None, + created_at: remixed_at, + updated_at: remixed_at, + }; + + if let Some(existing_target) = ctx + .db + .custom_world_profile() + .profile_id() + .find(&target_profile_id.to_string()) + .filter(|row| row.owner_user_id == target_owner_user_id) + { + ctx.db + .custom_world_profile() + .profile_id() + .delete(&existing_target.profile_id); + } + + let inserted_draft = ctx.db.custom_world_profile().insert(draft); + Ok(( + build_custom_world_profile_snapshot(&inserted_draft), + Some(source_gallery), + )) +} + +fn record_custom_world_profile_play_record( + ctx: &ReducerContext, + input: module_custom_world::CustomWorldProfilePlayRecordInput, +) -> Result<(CustomWorldProfileSnapshot, CustomWorldGalleryEntrySnapshot), String> { + let owner_user_id = input.owner_user_id.trim(); + let profile_id = input.profile_id.trim(); + if owner_user_id.is_empty() || profile_id.is_empty() { + return Err("custom_world play 参数不能为空".to_string()); + } + let existing = ctx + .db + .custom_world_profile() + .profile_id() + .find(&profile_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .filter(|row| { + row.publication_status == CustomWorldPublicationStatus::Published + && row.deleted_at.is_none() + && row.published_at.is_some() + }) + .ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?; + let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); + + record_public_work_play( + ctx, + PublicWorkPlayRecordInput { + source_type: "custom-world".to_string(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + played_at_micros: input.played_at_micros, + }, + )?; + + ctx.db + .custom_world_profile() + .profile_id() + .delete(&existing.profile_id); + let next_row = CustomWorldProfile { + profile_id: existing.profile_id.clone(), + owner_user_id: existing.owner_user_id.clone(), + public_work_code: existing.public_work_code.clone(), + author_public_user_code: existing.author_public_user_code.clone(), + source_agent_session_id: existing.source_agent_session_id.clone(), + publication_status: existing.publication_status, + world_name: existing.world_name.clone(), + subtitle: existing.subtitle.clone(), + summary_text: existing.summary_text.clone(), + theme_mode: existing.theme_mode, + cover_image_src: existing.cover_image_src.clone(), + profile_payload_json: existing.profile_payload_json.clone(), + playable_npc_count: existing.playable_npc_count, + landmark_count: existing.landmark_count, + play_count: existing.play_count.saturating_add(1), + remix_count: existing.remix_count, + like_count: existing.like_count, + author_display_name: existing.author_display_name.clone(), + published_at: existing.published_at, + deleted_at: existing.deleted_at, + created_at: existing.created_at, + updated_at: played_at, + }; + let inserted = ctx.db.custom_world_profile().insert(next_row); + let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; + + Ok(( + build_custom_world_profile_snapshot(&inserted), + gallery_entry, + )) +} + +fn record_custom_world_profile_like_record( + ctx: &ReducerContext, + input: module_custom_world::CustomWorldProfileLikeRecordInput, +) -> Result<(CustomWorldProfileSnapshot, CustomWorldGalleryEntrySnapshot), String> { + let owner_user_id = input.owner_user_id.trim(); + let profile_id = input.profile_id.trim(); + let user_id = input.user_id.trim(); + if owner_user_id.is_empty() || profile_id.is_empty() || user_id.is_empty() { + return Err("custom_world like 参数不能为空".to_string()); + } + let existing = ctx + .db + .custom_world_profile() + .profile_id() + .find(&profile_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .filter(|row| { + row.publication_status == CustomWorldPublicationStatus::Published + && row.deleted_at.is_none() + && row.published_at.is_some() + }) + .ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?; + let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros); + + let inserted_like = record_public_work_like( + ctx, + PublicWorkLikeRecordInput { + source_type: "custom-world".to_string(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + user_id: user_id.to_string(), + liked_at_micros: input.liked_at_micros, + }, + )?; + + if !inserted_like { + let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &existing)?; + return Ok(( + build_custom_world_profile_snapshot(&existing), + gallery_entry, + )); + } + + ctx.db + .custom_world_profile() + .profile_id() + .delete(&existing.profile_id); + let next_row = CustomWorldProfile { + profile_id: existing.profile_id.clone(), + owner_user_id: existing.owner_user_id.clone(), + public_work_code: existing.public_work_code.clone(), + author_public_user_code: existing.author_public_user_code.clone(), + source_agent_session_id: existing.source_agent_session_id.clone(), + publication_status: existing.publication_status, + world_name: existing.world_name.clone(), + subtitle: existing.subtitle.clone(), + summary_text: existing.summary_text.clone(), + theme_mode: existing.theme_mode, + cover_image_src: existing.cover_image_src.clone(), + profile_payload_json: existing.profile_payload_json.clone(), + playable_npc_count: existing.playable_npc_count, + landmark_count: existing.landmark_count, + play_count: existing.play_count, + remix_count: existing.remix_count, + like_count: existing.like_count.saturating_add(1), + author_display_name: existing.author_display_name.clone(), + published_at: existing.published_at, + deleted_at: existing.deleted_at, + created_at: existing.created_at, + updated_at: liked_at, + }; + let inserted = ctx.db.custom_world_profile().insert(next_row); + let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; + + Ok(( + build_custom_world_profile_snapshot(&inserted), + gallery_entry, )) } @@ -4438,13 +4796,16 @@ fn sync_custom_world_gallery_entry_from_profile( theme_mode: profile.theme_mode, playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, + play_count: profile.play_count, + remix_count: profile.remix_count, + like_count: profile.like_count, published_at, updated_at: profile.updated_at, }; let inserted = ctx.db.custom_world_gallery_entry().insert(row); - Ok(build_custom_world_gallery_entry_snapshot(&inserted)) + Ok(build_custom_world_gallery_entry_snapshot(ctx, &inserted)) } fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(), String> { @@ -4519,6 +4880,9 @@ fn ensure_custom_world_profile_public_fields( profile_payload_json: profile.profile_payload_json.clone(), playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, + play_count: profile.play_count, + remix_count: profile.remix_count, + like_count: profile.like_count, author_display_name: profile.author_display_name.clone(), published_at: profile.published_at, deleted_at: profile.deleted_at, @@ -4545,6 +4909,9 @@ fn build_custom_world_profile_row_copy(profile: &CustomWorldProfile) -> CustomWo profile_payload_json: profile.profile_payload_json.clone(), playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, + play_count: profile.play_count, + remix_count: profile.remix_count, + like_count: profile.like_count, author_display_name: profile.author_display_name.clone(), published_at: profile.published_at, deleted_at: profile.deleted_at, @@ -4569,6 +4936,9 @@ fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldP profile_payload_json: row.profile_payload_json.clone(), playable_npc_count: row.playable_npc_count, landmark_count: row.landmark_count, + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, author_display_name: row.author_display_name.clone(), published_at_micros: row .published_at @@ -4706,6 +5076,7 @@ fn build_custom_world_draft_card_snapshot( } fn build_custom_world_gallery_entry_snapshot( + ctx: &ReducerContext, row: &CustomWorldGalleryEntry, ) -> CustomWorldGalleryEntrySnapshot { CustomWorldGalleryEntrySnapshot { @@ -4721,6 +5092,15 @@ fn build_custom_world_gallery_entry_snapshot( theme_mode: row.theme_mode, playable_npc_count: row.playable_npc_count, landmark_count: row.landmark_count, + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, + recent_play_count_7d: count_recent_public_work_plays( + ctx, + "custom-world", + &row.profile_id, + ctx.timestamp.to_micros_since_unix_epoch(), + ), published_at_micros: row.published_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } @@ -4871,6 +5251,9 @@ mod tests { profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, + play_count: 0, + remix_count: 0, + like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: None, @@ -4892,6 +5275,9 @@ mod tests { profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, + play_count: 0, + remix_count: 0, + like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: Some(Timestamp::from_micros_since_unix_epoch(2)), @@ -4913,6 +5299,9 @@ mod tests { profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, + play_count: 0, + remix_count: 0, + like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: None, @@ -4973,6 +5362,9 @@ mod tests { profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, + play_count: 0, + remix_count: 0, + like_count: 0, author_display_name: "玩家".to_string(), published_at: if publication_status == CustomWorldPublicationStatus::Published { Some(Timestamp::from_micros_since_unix_epoch(2)) @@ -5034,6 +5426,9 @@ mod tests { profile_payload_json: "{}".to_string(), playable_npc_count: 0, landmark_count: 0, + play_count: 0, + remix_count: 0, + like_count: 0, author_display_name: "玩家".to_string(), published_at: None, deleted_at: None, diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 898a7f0c..ce79efe6 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -1,4 +1,4 @@ -// 中文注释:SpacetimeDB 绑定生成依赖根模块继续公开 re-export 各领域类型; +// 中文注释:SpacetimeDB 绑定生成依赖根模块继续公开 re-export 各领域类型; // 少数领域 helper 同名只影响 value namespace 导出,不影响 table / reducer 类型。 #![allow(ambiguous_glob_reexports)] @@ -28,6 +28,7 @@ mod custom_world; mod domain_types; mod entry; mod gameplay; +mod match3d; mod migration; mod puzzle; mod runtime; @@ -40,5 +41,6 @@ pub use custom_world::*; pub use domain_types::*; pub use entry::*; pub use gameplay::*; +pub use match3d::*; pub use migration::*; pub use runtime::*; diff --git a/server-rs/crates/spacetime-module/src/match3d/mod.rs b/server-rs/crates/spacetime-module/src/match3d/mod.rs new file mode 100644 index 00000000..f1c14ade --- /dev/null +++ b/server-rs/crates/spacetime-module/src/match3d/mod.rs @@ -0,0 +1,1641 @@ +pub(crate) mod tables; +mod types; + +pub use tables::*; +pub use types::*; + +use crate::*; +use module_match3d::{ + Match3DClickInput as DomainMatch3DClickInput, + Match3DClickRejectReason as DomainMatch3DClickRejectReason, + Match3DCreatorConfig as DomainMatch3DCreatorConfig, + Match3DFailureReason as DomainMatch3DFailureReason, + Match3DItemSnapshot as DomainMatch3DItemSnapshot, Match3DItemState as DomainMatch3DItemState, + Match3DRunSnapshot as DomainMatch3DRunSnapshot, Match3DRunStatus as DomainMatch3DRunStatus, + Match3DTraySlot as DomainMatch3DTraySlot, confirm_click_at as confirm_domain_click_at, + resolve_run_timer_at as resolve_domain_run_timer_at, start_run_with_seed_at, + stop_run_at as stop_domain_run_at, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +#[spacetimedb::procedure] +pub fn create_match3d_agent_session( + ctx: &mut ProcedureContext, + input: Match3DAgentSessionCreateInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| create_match3d_agent_session_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_match3d_agent_session( + ctx: &mut ProcedureContext, + input: Match3DAgentSessionGetInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| get_match3d_agent_session_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn submit_match3d_agent_message( + ctx: &mut ProcedureContext, + input: Match3DAgentMessageSubmitInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| submit_match3d_agent_message_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn finalize_match3d_agent_message_turn( + ctx: &mut ProcedureContext, + input: Match3DAgentMessageFinalizeInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| finalize_match3d_agent_message_turn_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn compile_match3d_draft( + ctx: &mut ProcedureContext, + input: Match3DDraftCompileInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| compile_match3d_draft_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn update_match3d_work( + ctx: &mut ProcedureContext, + input: Match3DWorkUpdateInput, +) -> Match3DWorkProcedureResult { + match ctx.try_with_tx(|tx| update_match3d_work_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn publish_match3d_work( + ctx: &mut ProcedureContext, + input: Match3DWorkPublishInput, +) -> Match3DWorkProcedureResult { + match ctx.try_with_tx(|tx| publish_match3d_work_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn list_match3d_works( + ctx: &mut ProcedureContext, + input: Match3DWorksListInput, +) -> Match3DWorksProcedureResult { + match ctx.try_with_tx(|tx| list_match3d_works_tx(tx, input.clone())) { + Ok(items) => Match3DWorksProcedureResult { + ok: true, + items_json: Some(to_json_string(&items)), + error_message: None, + }, + Err(message) => Match3DWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_match3d_work_detail( + ctx: &mut ProcedureContext, + input: Match3DWorkGetInput, +) -> Match3DWorkProcedureResult { + match ctx.try_with_tx(|tx| get_match3d_work_detail_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn delete_match3d_work( + ctx: &mut ProcedureContext, + input: Match3DWorkDeleteInput, +) -> Match3DWorksProcedureResult { + match ctx.try_with_tx(|tx| delete_match3d_work_tx(tx, input.clone())) { + Ok(items) => Match3DWorksProcedureResult { + ok: true, + items_json: Some(to_json_string(&items)), + error_message: None, + }, + Err(message) => Match3DWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn start_match3d_run( + ctx: &mut ProcedureContext, + input: Match3DRunStartInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| start_match3d_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_match3d_run( + ctx: &mut ProcedureContext, + input: Match3DRunGetInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| get_match3d_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn click_match3d_item( + ctx: &mut ProcedureContext, + input: Match3DRunClickInput, +) -> Match3DClickItemProcedureResult { + match ctx.try_with_tx(|tx| click_match3d_item_tx(tx, input.clone())) { + Ok(result) => result, + Err(message) => Match3DClickItemProcedureResult { + ok: false, + status: MATCH3D_CLICK_REJECTED_NOT_CLICKABLE.to_string(), + run_json: None, + accepted_item_instance_id: None, + cleared_item_instance_ids: Vec::new(), + failure_reason: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn stop_match3d_run( + ctx: &mut ProcedureContext, + input: Match3DRunStopInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| stop_match3d_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn restart_match3d_run( + ctx: &mut ProcedureContext, + input: Match3DRunRestartInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| restart_match3d_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn finish_match3d_time_up( + ctx: &mut ProcedureContext, + input: Match3DRunTimeUpInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| finish_match3d_time_up_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +fn create_match3d_agent_session_tx( + ctx: &ReducerContext, + input: Match3DAgentSessionCreateInput, +) -> Result { + require_non_empty(&input.session_id, "match3d session_id")?; + require_non_empty(&input.owner_user_id, "match3d owner_user_id")?; + require_non_empty(&input.welcome_message_id, "match3d welcome_message_id")?; + if ctx + .db + .match3d_agent_session() + .session_id() + .find(&input.session_id) + .is_some() + { + return Err("match3d_agent_session.session_id 已存在".to_string()); + } + if ctx + .db + .match3d_agent_message() + .message_id() + .find(&input.welcome_message_id) + .is_some() + { + return Err("match3d_agent_message.message_id 已存在".to_string()); + } + + let config = input + .config_json + .as_deref() + .map(parse_config) + .transpose()? + .unwrap_or_else(|| default_config_from_seed(&input.seed_text)); + validate_config(&config)?; + let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); + let welcome = input.welcome_message_text.trim(); + + ctx.db + .match3d_agent_session() + .insert(Match3DAgentSessionRow { + session_id: input.session_id.clone(), + owner_user_id: input.owner_user_id.clone(), + seed_text: input.seed_text.trim().to_string(), + current_turn: 0, + progress_percent: 0, + stage: MATCH3D_STAGE_COLLECTING.to_string(), + config_json: to_json_string(&config), + draft_json: String::new(), + last_assistant_reply: welcome.to_string(), + published_profile_id: String::new(), + created_at, + updated_at: created_at, + }); + ctx.db + .match3d_agent_message() + .insert(Match3DAgentMessageRow { + message_id: input.welcome_message_id, + session_id: input.session_id.clone(), + role: MATCH3D_ROLE_ASSISTANT.to_string(), + kind: MATCH3D_KIND_TEXT.to_string(), + text: welcome.to_string(), + created_at, + }); + + get_match3d_agent_session_tx( + ctx, + Match3DAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn get_match3d_agent_session_tx( + ctx: &ReducerContext, + input: Match3DAgentSessionGetInput, +) -> Result { + let row = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + build_session_snapshot(ctx, &row) +} + +fn submit_match3d_agent_message_tx( + ctx: &ReducerContext, + input: Match3DAgentMessageSubmitInput, +) -> Result { + require_non_empty(&input.user_message_id, "match3d user_message_id")?; + require_non_empty(&input.user_message_text, "match3d user_message_text")?; + let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + if ctx + .db + .match3d_agent_message() + .message_id() + .find(&input.user_message_id) + .is_some() + { + return Err("match3d_agent_message.user_message_id 已存在".to_string()); + } + + let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); + ctx.db + .match3d_agent_message() + .insert(Match3DAgentMessageRow { + message_id: input.user_message_id, + session_id: input.session_id.clone(), + role: MATCH3D_ROLE_USER.to_string(), + kind: MATCH3D_KIND_TEXT.to_string(), + text: input.user_message_text.trim().to_string(), + created_at: submitted_at, + }); + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + updated_at: submitted_at, + ..clone_session(&session) + }, + ); + + get_match3d_agent_session_tx( + ctx, + Match3DAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn finalize_match3d_agent_message_turn_tx( + ctx: &ReducerContext, + input: Match3DAgentMessageFinalizeInput, +) -> Result { + let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + if let Some(message) = input + .error_message + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + updated_at, + ..clone_session(&session) + }, + ); + return Err(message.to_string()); + } + + let next_config = input + .config_json + .as_deref() + .map(parse_config) + .transpose()? + .unwrap_or_else(|| parse_config_or_default(&session.config_json)); + validate_config(&next_config)?; + let assistant_text = input + .assistant_reply_text + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&session.last_assistant_reply) + .to_string(); + if let Some(message_id) = input + .assistant_message_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if ctx + .db + .match3d_agent_message() + .message_id() + .find(&message_id.to_string()) + .is_some() + { + return Err("match3d_agent_message.assistant_message_id 已存在".to_string()); + } + ctx.db + .match3d_agent_message() + .insert(Match3DAgentMessageRow { + message_id: message_id.to_string(), + session_id: input.session_id.clone(), + role: MATCH3D_ROLE_ASSISTANT.to_string(), + kind: MATCH3D_KIND_TEXT.to_string(), + text: assistant_text.clone(), + created_at: updated_at, + }); + } + + let next_stage = normalize_stage(&input.stage); + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + current_turn: session.current_turn.saturating_add(1), + progress_percent: input.progress_percent.min(100), + stage: next_stage, + config_json: to_json_string(&next_config), + last_assistant_reply: assistant_text, + updated_at, + ..clone_session(&session) + }, + ); + + get_match3d_agent_session_tx( + ctx, + Match3DAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn compile_match3d_draft_tx( + ctx: &ReducerContext, + input: Match3DDraftCompileInput, +) -> Result { + require_non_empty(&input.profile_id, "match3d profile_id")?; + let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + let config = parse_config(&session.config_json)?; + validate_config(&config)?; + let tags = input + .tags_json + .as_deref() + .map(parse_tags) + .transpose()? + .filter(|items| !items.is_empty()) + .unwrap_or_else(|| default_tags(&config.theme_text)); + let game_name = + clean_optional(&input.game_name).unwrap_or_else(|| format!("{}抓大鹅", config.theme_text)); + let summary_text = clean_optional(&input.summary_text) + .unwrap_or_else(|| format!("{}主题的经典消除玩法。", config.theme_text)); + let draft = Match3DDraftSnapshot { + profile_id: input.profile_id.clone(), + game_name: game_name.clone(), + theme_text: config.theme_text.clone(), + summary_text: summary_text.clone(), + tags: tags.clone(), + clear_count: config.clear_count, + difficulty: config.difficulty, + }; + let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); + let work = Match3DWorkProfileRow { + profile_id: input.profile_id.clone(), + owner_user_id: input.owner_user_id.clone(), + source_session_id: input.session_id.clone(), + author_display_name: clean_string(&input.author_display_name, "百梦主"), + game_name, + theme_text: config.theme_text.clone(), + summary_text, + tags_json: to_json_string(&tags), + cover_image_src: clean_optional(&input.cover_image_src).unwrap_or_default(), + cover_asset_id: clean_optional(&input.cover_asset_id).unwrap_or_default(), + clear_count: config.clear_count, + difficulty: config.difficulty, + config_json: to_json_string(&config), + publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(), + play_count: 0, + updated_at: compiled_at, + published_at: None, + }; + upsert_work(ctx, work); + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + progress_percent: 80, + stage: MATCH3D_STAGE_DRAFT_COMPILED.to_string(), + draft_json: to_json_string(&draft), + published_profile_id: input.profile_id, + last_assistant_reply: "抓大鹅玩法草稿已生成,可以进入结果页编辑基础信息并试玩。" + .to_string(), + updated_at: compiled_at, + ..clone_session(&session) + }, + ); + + get_match3d_agent_session_tx( + ctx, + Match3DAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn update_match3d_work_tx( + ctx: &ReducerContext, + input: Match3DWorkUpdateInput, +) -> Result { + let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + let tags = parse_tags(&input.tags_json)?; + let config = Match3DCreatorConfigSnapshot { + theme_text: clean_string(&input.theme_text, "经典消除"), + reference_image_src: parse_config_or_default(¤t.config_json).reference_image_src, + clear_count: input.clear_count, + difficulty: input.difficulty, + }; + validate_config(&config)?; + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let next = Match3DWorkProfileRow { + profile_id: current.profile_id.clone(), + owner_user_id: current.owner_user_id.clone(), + source_session_id: current.source_session_id.clone(), + author_display_name: current.author_display_name.clone(), + game_name: clean_string(&input.game_name, "未命名抓大鹅"), + theme_text: config.theme_text.clone(), + summary_text: clean_string(&input.summary_text, "经典消除玩法"), + tags_json: to_json_string(&tags), + cover_image_src: input.cover_image_src.trim().to_string(), + cover_asset_id: input.cover_asset_id.trim().to_string(), + clear_count: config.clear_count, + difficulty: config.difficulty, + config_json: to_json_string(&config), + publication_status: current.publication_status.clone(), + play_count: current.play_count, + updated_at, + published_at: current.published_at, + }; + let snapshot = build_work_snapshot(&next)?; + replace_work(ctx, ¤t, next); + Ok(snapshot) +} + +fn publish_match3d_work_tx( + ctx: &ReducerContext, + input: Match3DWorkPublishInput, +) -> Result { + let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + validate_publishable_work(¤t)?; + let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); + let next = Match3DWorkProfileRow { + publication_status: MATCH3D_PUBLICATION_PUBLISHED.to_string(), + updated_at: published_at, + published_at: Some(published_at), + ..clone_work(¤t) + }; + let snapshot = build_work_snapshot(&next)?; + if !next.source_session_id.is_empty() { + if let Some(session) = ctx + .db + .match3d_agent_session() + .session_id() + .find(&next.source_session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + { + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + progress_percent: 100, + stage: MATCH3D_STAGE_PUBLISHED.to_string(), + published_profile_id: next.profile_id.clone(), + updated_at: published_at, + ..clone_session(&session) + }, + ); + } + } + replace_work(ctx, ¤t, next); + Ok(snapshot) +} + +fn list_match3d_works_tx( + ctx: &ReducerContext, + input: Match3DWorksListInput, +) -> Result, String> { + let mut items = ctx + .db + .match3d_work_profile() + .iter() + .filter(|row| { + if input.published_only { + row.publication_status == MATCH3D_PUBLICATION_PUBLISHED + } else { + row.owner_user_id == input.owner_user_id + } + }) + .map(|row| build_work_snapshot(&row)) + .collect::, _>>()?; + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + Ok(items) +} + +fn get_match3d_work_detail_tx( + ctx: &ReducerContext, + input: Match3DWorkGetInput, +) -> Result { + let row = ctx + .db + .match3d_work_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| { + row.owner_user_id == input.owner_user_id + || row.publication_status == MATCH3D_PUBLICATION_PUBLISHED + }) + .ok_or_else(|| "match3d_work_profile 不存在".to_string())?; + build_work_snapshot(&row) +} + +fn delete_match3d_work_tx( + ctx: &ReducerContext, + input: Match3DWorkDeleteInput, +) -> Result, String> { + let work = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + ctx.db + .match3d_work_profile() + .profile_id() + .delete(&work.profile_id); + for run in ctx + .db + .match3d_runtime_run() + .iter() + .filter(|row| { + row.profile_id == input.profile_id && row.owner_user_id == input.owner_user_id + }) + .collect::>() + { + ctx.db.match3d_runtime_run().run_id().delete(&run.run_id); + } + list_match3d_works_tx( + ctx, + Match3DWorksListInput { + owner_user_id: input.owner_user_id, + published_only: false, + }, + ) +} + +fn start_match3d_run_tx( + ctx: &ReducerContext, + input: Match3DRunStartInput, +) -> Result { + require_non_empty(&input.run_id, "match3d run_id")?; + if ctx + .db + .match3d_runtime_run() + .run_id() + .find(&input.run_id) + .is_some() + { + return Err("match3d_runtime_run.run_id 已存在".to_string()); + } + let work = ctx + .db + .match3d_work_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| { + row.owner_user_id == input.owner_user_id + || row.publication_status == MATCH3D_PUBLICATION_PUBLISHED + }) + .ok_or_else(|| "match3d_work_profile 不存在".to_string())?; + let started_at_ms = if input.started_at_ms > 0 { + input.started_at_ms + } else { + current_server_ms(ctx) + }; + let mut snapshot = build_initial_run_snapshot(&input.run_id, &work, started_at_ms); + snapshot.server_now_ms = current_server_ms(ctx); + snapshot.remaining_ms = compute_remaining_ms(&snapshot, snapshot.server_now_ms); + let now = ctx.timestamp; + ctx.db.match3d_runtime_run().insert(row_from_snapshot( + &input.owner_user_id, + &snapshot, + now, + now, + )); + + Ok(snapshot) +} + +fn get_match3d_run_tx( + ctx: &ReducerContext, + input: Match3DRunGetInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let snapshot = deserialize_snapshot(&row.snapshot_json)?; + let next = confirm_time_up_if_needed(ctx, &row, snapshot, current_server_ms(ctx))?; + Ok(next) +} + +fn click_match3d_item_tx( + ctx: &ReducerContext, + input: Match3DRunClickInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let snapshot = deserialize_snapshot(&row.snapshot_json)?; + let server_now_ms = current_server_ms(ctx); + let snapshot = confirm_time_up_if_needed(ctx, &row, snapshot, server_now_ms)?; + if snapshot.status != MATCH3D_RUN_RUNNING { + return Ok(click_result( + MATCH3D_CLICK_RUN_FINISHED, + snapshot, + None, + Vec::new(), + )); + } + if snapshot.snapshot_version != input.client_snapshot_version { + return Ok(click_result( + MATCH3D_CLICK_VERSION_CONFLICT, + snapshot, + None, + Vec::new(), + )); + } + let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); + let confirmation = confirm_domain_click_at( + &domain_run, + &DomainMatch3DClickInput { + run_id: input.run_id.clone(), + owner_user_id: input.owner_user_id.clone(), + item_instance_id: input.item_instance_id.clone(), + client_action_id: clean_string(&input.client_event_id, "match3d-action"), + snapshot_version: input.client_snapshot_version as u64, + clicked_at_ms: to_u64_ms(server_now_ms), + }, + ) + .map_err(|error| error.to_string())?; + let next = snapshot_from_domain(&confirmation.run, server_now_ms); + let status = if confirmation.accepted { + MATCH3D_CLICK_ACCEPTED + } else { + match confirmation.reject_reason { + Some(DomainMatch3DClickRejectReason::RunNotActive) => MATCH3D_CLICK_RUN_FINISHED, + Some(DomainMatch3DClickRejectReason::SnapshotVersionMismatch) => { + MATCH3D_CLICK_VERSION_CONFLICT + } + Some(DomainMatch3DClickRejectReason::ItemNotFound) + | Some(DomainMatch3DClickRejectReason::ItemNotInBoard) => { + MATCH3D_CLICK_REJECTED_ALREADY_MOVED + } + Some(DomainMatch3DClickRejectReason::ItemNotClickable) => { + MATCH3D_CLICK_REJECTED_NOT_CLICKABLE + } + Some(DomainMatch3DClickRejectReason::TrayFull) => MATCH3D_CLICK_REJECTED_TRAY_FULL, + None => MATCH3D_CLICK_REJECTED_NOT_CLICKABLE, + } + }; + if confirmation.accepted + || status == MATCH3D_CLICK_REJECTED_TRAY_FULL + || next.status != snapshot.status + || next.snapshot_version != snapshot.snapshot_version + { + persist_snapshot(ctx, &row, &next, server_now_ms); + } + Ok(click_result( + status, + next, + confirmation.accepted.then_some(input.item_instance_id), + confirmation.cleared_item_instance_ids, + )) +} + +fn stop_match3d_run_tx( + ctx: &ReducerContext, + input: Match3DRunStopInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let stopped_at_ms = input.stopped_at_ms.max(current_server_ms(ctx)); + let snapshot = deserialize_snapshot(&row.snapshot_json)?; + let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); + let domain_run = resolve_domain_run_timer_at(&domain_run, to_u64_ms(stopped_at_ms)); + let domain_run = stop_domain_run_at(&domain_run, "match3d-stop".to_string()); + let next = snapshot_from_domain(&domain_run, stopped_at_ms); + persist_snapshot(ctx, &row, &next, stopped_at_ms); + Ok(next) +} + +fn restart_match3d_run_tx( + ctx: &ReducerContext, + input: Match3DRunRestartInput, +) -> Result { + let source = find_owned_run(ctx, &input.source_run_id, &input.owner_user_id)?; + start_match3d_run_tx( + ctx, + Match3DRunStartInput { + run_id: input.next_run_id, + owner_user_id: input.owner_user_id, + profile_id: source.profile_id, + started_at_ms: input.restarted_at_ms, + }, + ) +} + +fn finish_match3d_time_up_tx( + ctx: &ReducerContext, + input: Match3DRunTimeUpInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let snapshot = deserialize_snapshot(&row.snapshot_json)?; + let finished_at_ms = input.finished_at_ms.max(current_server_ms(ctx)); + let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); + let domain_run = resolve_domain_run_timer_at(&domain_run, to_u64_ms(finished_at_ms)); + let next = snapshot_from_domain(&domain_run, finished_at_ms); + persist_snapshot(ctx, &row, &next, finished_at_ms); + Ok(next) +} + +fn find_owned_session( + ctx: &ReducerContext, + session_id: &str, + owner_user_id: &str, +) -> Result { + require_non_empty(session_id, "match3d session_id")?; + require_non_empty(owner_user_id, "match3d owner_user_id")?; + ctx.db + .match3d_agent_session() + .session_id() + .find(&session_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .ok_or_else(|| "match3d_agent_session 不存在".to_string()) +} + +fn find_owned_work( + ctx: &ReducerContext, + profile_id: &str, + owner_user_id: &str, +) -> Result { + require_non_empty(profile_id, "match3d profile_id")?; + require_non_empty(owner_user_id, "match3d owner_user_id")?; + ctx.db + .match3d_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .ok_or_else(|| "match3d_work_profile 不存在".to_string()) +} + +fn find_owned_run( + ctx: &ReducerContext, + run_id: &str, + owner_user_id: &str, +) -> Result { + require_non_empty(run_id, "match3d run_id")?; + require_non_empty(owner_user_id, "match3d owner_user_id")?; + ctx.db + .match3d_runtime_run() + .run_id() + .find(&run_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .ok_or_else(|| "match3d_runtime_run 不存在".to_string()) +} + +fn build_session_snapshot( + ctx: &ReducerContext, + row: &Match3DAgentSessionRow, +) -> Result { + let mut messages = ctx + .db + .match3d_agent_message() + .iter() + .filter(|message| message.session_id == row.session_id) + .map(|message| Match3DAgentMessageSnapshot { + message_id: message.message_id, + session_id: message.session_id, + role: message.role, + kind: message.kind, + text: message.text, + created_at_micros: message.created_at.to_micros_since_unix_epoch(), + }) + .collect::>(); + messages.sort_by(|left, right| { + left.created_at_micros + .cmp(&right.created_at_micros) + .then_with(|| left.message_id.cmp(&right.message_id)) + }); + let config = parse_config(&row.config_json)?; + let draft = if row.draft_json.trim().is_empty() { + None + } else { + Some(parse_json::( + &row.draft_json, + "match3d draft_json", + )?) + }; + + Ok(Match3DAgentSessionSnapshot { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + seed_text: row.seed_text.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent, + stage: row.stage.clone(), + config, + draft, + messages, + last_assistant_reply: row.last_assistant_reply.clone(), + published_profile_id: empty_to_none(&row.published_profile_id), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + }) +} + +fn build_work_snapshot(row: &Match3DWorkProfileRow) -> Result { + let config = parse_config(&row.config_json)?; + let tags = parse_tags(&row.tags_json)?; + Ok(Match3DWorkSnapshot { + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + game_name: row.game_name.clone(), + theme_text: row.theme_text.clone(), + summary_text: row.summary_text.clone(), + tags, + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + clear_count: row.clear_count, + difficulty: row.difficulty, + config, + publication_status: row.publication_status.clone(), + publish_ready: is_work_publish_ready(row), + play_count: row.play_count, + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + published_at_micros: row + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + }) +} + +fn build_initial_run_snapshot( + run_id: &str, + work: &Match3DWorkProfileRow, + started_at_ms: i64, +) -> Match3DRunSnapshot { + let config = parse_config_or_default(&work.config_json); + let domain_config = + domain_config_from_snapshot(&config).unwrap_or_else(|_| fallback_domain_config()); + let domain_started_at_ms = to_u64_ms(started_at_ms); + let seed = deterministic_run_seed(run_id, &work.profile_id, work.clear_count, work.difficulty); + let domain_run = start_run_with_seed_at( + run_id.to_string(), + work.owner_user_id.clone(), + work.profile_id.clone(), + &domain_config, + seed, + domain_started_at_ms, + ) + .unwrap_or_else(|_| DomainMatch3DRunSnapshot { + run_id: run_id.to_string(), + profile_id: work.profile_id.clone(), + owner_user_id: work.owner_user_id.clone(), + status: DomainMatch3DRunStatus::Running, + started_at_ms: domain_started_at_ms, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS as u64, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS as u64, + clear_count: work.clear_count.max(1), + total_item_count: work.clear_count.max(1).saturating_mul(3), + cleared_item_count: 0, + board_version: 1, + items: Vec::new(), + tray_slots: Vec::new(), + failure_reason: None, + last_confirmed_action_id: None, + }); + snapshot_from_domain(&domain_run, started_at_ms) +} + +fn fallback_domain_config() -> DomainMatch3DCreatorConfig { + DomainMatch3DCreatorConfig { + theme_text: "经典消除".to_string(), + reference_image_src: None, + clear_count: 1, + difficulty: 3, + } +} + +fn confirm_time_up_if_needed( + ctx: &ReducerContext, + row: &Match3DRuntimeRunRow, + snapshot: Match3DRunSnapshot, + server_now_ms: i64, +) -> Result { + if snapshot.status != MATCH3D_RUN_RUNNING || compute_remaining_ms(&snapshot, server_now_ms) > 0 + { + let mut next = snapshot; + next.server_now_ms = server_now_ms; + next.remaining_ms = compute_remaining_ms(&next, server_now_ms); + return Ok(next); + } + let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); + let domain_run = resolve_domain_run_timer_at(&domain_run, to_u64_ms(server_now_ms)); + let next = snapshot_from_domain(&domain_run, server_now_ms); + persist_snapshot(ctx, row, &next, server_now_ms); + Ok(next) +} + +fn persist_snapshot( + ctx: &ReducerContext, + row: &Match3DRuntimeRunRow, + snapshot: &Match3DRunSnapshot, + server_now_ms: i64, +) { + let updated_at = Timestamp::from_micros_since_unix_epoch(server_now_ms.saturating_mul(1000)); + let next = row_from_snapshot(&row.owner_user_id, snapshot, row.created_at, updated_at); + ctx.db.match3d_runtime_run().run_id().delete(&row.run_id); + ctx.db.match3d_runtime_run().insert(next); +} + +fn row_from_snapshot( + owner_user_id: &str, + snapshot: &Match3DRunSnapshot, + created_at: Timestamp, + updated_at: Timestamp, +) -> Match3DRuntimeRunRow { + let finished_at_ms = if snapshot.status == MATCH3D_RUN_RUNNING { + 0 + } else { + snapshot.server_now_ms + }; + let elapsed_ms = if finished_at_ms > 0 { + finished_at_ms.saturating_sub(snapshot.started_at_ms) + } else { + snapshot + .server_now_ms + .saturating_sub(snapshot.started_at_ms) + }; + Match3DRuntimeRunRow { + run_id: snapshot.run_id.clone(), + owner_user_id: owner_user_id.to_string(), + profile_id: snapshot.profile_id.clone(), + status: snapshot.status.clone(), + snapshot_version: snapshot.snapshot_version, + started_at_ms: snapshot.started_at_ms, + duration_limit_ms: snapshot.duration_limit_ms, + finished_at_ms, + elapsed_ms, + clear_count: snapshot.clear_count, + total_item_count: snapshot.total_item_count, + cleared_item_count: snapshot.cleared_item_count, + failure_reason: snapshot.failure_reason.clone().unwrap_or_default(), + snapshot_json: to_json_string(snapshot), + created_at, + updated_at, + } +} + +fn click_result( + status: &str, + snapshot: Match3DRunSnapshot, + accepted_item_instance_id: Option, + cleared_item_instance_ids: Vec, +) -> Match3DClickItemProcedureResult { + Match3DClickItemProcedureResult { + ok: true, + status: status.to_string(), + run_json: Some(to_json_string(&snapshot)), + accepted_item_instance_id, + cleared_item_instance_ids, + failure_reason: snapshot.failure_reason, + error_message: None, + } +} + +fn upsert_work(ctx: &ReducerContext, work: Match3DWorkProfileRow) { + if ctx + .db + .match3d_work_profile() + .profile_id() + .find(&work.profile_id) + .is_some() + { + ctx.db + .match3d_work_profile() + .profile_id() + .delete(&work.profile_id); + } + ctx.db.match3d_work_profile().insert(work); +} + +fn replace_session( + ctx: &ReducerContext, + current: &Match3DAgentSessionRow, + next: Match3DAgentSessionRow, +) { + ctx.db + .match3d_agent_session() + .session_id() + .delete(¤t.session_id); + ctx.db.match3d_agent_session().insert(next); +} + +fn replace_work( + ctx: &ReducerContext, + current: &Match3DWorkProfileRow, + next: Match3DWorkProfileRow, +) { + ctx.db + .match3d_work_profile() + .profile_id() + .delete(¤t.profile_id); + ctx.db.match3d_work_profile().insert(next); +} + +fn clone_session(row: &Match3DAgentSessionRow) -> Match3DAgentSessionRow { + Match3DAgentSessionRow { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + seed_text: row.seed_text.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent, + stage: row.stage.clone(), + config_json: row.config_json.clone(), + draft_json: row.draft_json.clone(), + last_assistant_reply: row.last_assistant_reply.clone(), + published_profile_id: row.published_profile_id.clone(), + created_at: row.created_at, + updated_at: row.updated_at, + } +} + +fn clone_work(row: &Match3DWorkProfileRow) -> Match3DWorkProfileRow { + Match3DWorkProfileRow { + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + game_name: row.game_name.clone(), + theme_text: row.theme_text.clone(), + summary_text: row.summary_text.clone(), + tags_json: row.tags_json.clone(), + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + clear_count: row.clear_count, + difficulty: row.difficulty, + config_json: row.config_json.clone(), + publication_status: row.publication_status.clone(), + play_count: row.play_count, + updated_at: row.updated_at, + published_at: row.published_at, + } +} + +fn validate_config(config: &Match3DCreatorConfigSnapshot) -> Result<(), String> { + domain_config_from_snapshot(config) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn validate_publishable_work(row: &Match3DWorkProfileRow) -> Result<(), String> { + if row.game_name.trim().is_empty() { + return Err("match3d 发布需要填写游戏名称".to_string()); + } + if row.cover_image_src.trim().is_empty() { + return Err("match3d 发布需要封面图".to_string()); + } + if parse_tags(&row.tags_json)?.is_empty() { + return Err("match3d 发布需要至少 1 个标签".to_string()); + } + validate_config(&parse_config(&row.config_json)?) +} + +fn is_work_publish_ready(row: &Match3DWorkProfileRow) -> bool { + validate_publishable_work(row).is_ok() +} + +fn default_config_from_seed(seed_text: &str) -> Match3DCreatorConfigSnapshot { + Match3DCreatorConfigSnapshot { + theme_text: clean_string(seed_text, "经典消除"), + reference_image_src: None, + clear_count: 12, + difficulty: 3, + } +} + +fn parse_config_or_default(value: &str) -> Match3DCreatorConfigSnapshot { + parse_config(value).unwrap_or_else(|_| default_config_from_seed("经典消除")) +} + +fn parse_config(value: &str) -> Result { + parse_json(value, "match3d config_json").map(|mut config: Match3DCreatorConfigSnapshot| { + config.theme_text = clean_string(&config.theme_text, "经典消除"); + config.difficulty = config + .difficulty + .clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY); + config + }) +} + +fn parse_tags(value: &str) -> Result, String> { + let parsed = parse_json::>(value, "match3d tags_json")?; + Ok(normalize_tags(parsed)) +} + +fn default_tags(theme_text: &str) -> Vec { + normalize_tags(vec![ + theme_text.to_string(), + "抓大鹅".to_string(), + "消除".to_string(), + ]) +} + +fn normalize_tags(tags: Vec) -> Vec { + let mut result = Vec::new(); + for tag in tags { + let trimmed = tag.trim(); + if !trimmed.is_empty() && !result.iter().any(|item: &String| item == trimmed) { + result.push(trimmed.to_string()); + } + if result.len() >= 6 { + break; + } + } + result +} + +fn normalize_stage(value: &str) -> String { + match value.trim() { + MATCH3D_STAGE_READY_TO_COMPILE => MATCH3D_STAGE_READY_TO_COMPILE.to_string(), + MATCH3D_STAGE_DRAFT_COMPILED => MATCH3D_STAGE_DRAFT_COMPILED.to_string(), + MATCH3D_STAGE_PUBLISHED => MATCH3D_STAGE_PUBLISHED.to_string(), + _ => MATCH3D_STAGE_COLLECTING.to_string(), + } +} + +fn domain_config_from_snapshot( + config: &Match3DCreatorConfigSnapshot, +) -> Result { + module_match3d::build_creator_config( + &config.theme_text, + config.reference_image_src.clone(), + config.clear_count, + config.difficulty, + ) +} + +fn snapshot_from_domain(run: &DomainMatch3DRunSnapshot, server_now_ms: i64) -> Match3DRunSnapshot { + Match3DRunSnapshot { + run_id: run.run_id.clone(), + profile_id: run.profile_id.clone(), + status: domain_status_to_text(run.status).to_string(), + snapshot_version: run.board_version.min(u32::MAX as u64) as u32, + started_at_ms: run.started_at_ms.min(i64::MAX as u64) as i64, + duration_limit_ms: run.duration_limit_ms.min(i64::MAX as u64) as i64, + server_now_ms, + remaining_ms: run.remaining_ms.min(i64::MAX as u64) as i64, + clear_count: run.clear_count, + total_item_count: run.total_item_count, + cleared_item_count: run.cleared_item_count, + tray_slots: run + .tray_slots + .iter() + .map(snapshot_tray_slot_from_domain) + .collect(), + items: run.items.iter().map(snapshot_item_from_domain).collect(), + failure_reason: run + .failure_reason + .map(domain_failure_to_text) + .map(str::to_string), + } +} + +fn domain_snapshot_from_snapshot( + snapshot: &Match3DRunSnapshot, + owner_user_id: &str, +) -> DomainMatch3DRunSnapshot { + DomainMatch3DRunSnapshot { + run_id: snapshot.run_id.clone(), + profile_id: snapshot.profile_id.clone(), + owner_user_id: owner_user_id.to_string(), + status: domain_status_from_text(&snapshot.status), + started_at_ms: to_u64_ms(snapshot.started_at_ms), + duration_limit_ms: to_u64_ms(snapshot.duration_limit_ms), + remaining_ms: to_u64_ms(snapshot.remaining_ms), + clear_count: snapshot.clear_count, + total_item_count: snapshot.total_item_count, + cleared_item_count: snapshot.cleared_item_count, + board_version: snapshot.snapshot_version as u64, + items: snapshot + .items + .iter() + .map(domain_item_from_snapshot) + .collect(), + tray_slots: snapshot + .tray_slots + .iter() + .map(domain_tray_slot_from_snapshot) + .collect(), + failure_reason: snapshot + .failure_reason + .as_deref() + .map(domain_failure_from_text), + last_confirmed_action_id: None, + } +} + +fn snapshot_item_from_domain(item: &DomainMatch3DItemSnapshot) -> Match3DItemSnapshot { + Match3DItemSnapshot { + item_instance_id: item.item_instance_id.clone(), + item_type_id: item.item_type_id.clone(), + visual_key: item.visual_key.clone(), + x: item.x, + y: item.y, + radius: item.radius, + layer: item.layer, + state: domain_item_state_to_text(item.state).to_string(), + clickable: item.clickable, + } +} + +fn domain_item_from_snapshot(item: &Match3DItemSnapshot) -> DomainMatch3DItemSnapshot { + DomainMatch3DItemSnapshot { + item_instance_id: item.item_instance_id.clone(), + item_type_id: item.item_type_id.clone(), + visual_key: item.visual_key.clone(), + x: item.x, + y: item.y, + radius: item.radius, + layer: item.layer, + state: domain_item_state_from_text(&item.state), + clickable: item.clickable, + tray_slot_index: None, + } +} + +fn snapshot_tray_slot_from_domain(slot: &DomainMatch3DTraySlot) -> Match3DTraySlotSnapshot { + Match3DTraySlotSnapshot { + slot_index: slot.slot_index, + item_instance_id: slot.item_instance_id.clone(), + item_type_id: slot.item_type_id.clone(), + visual_key: slot.visual_key.clone(), + } +} + +fn domain_tray_slot_from_snapshot(slot: &Match3DTraySlotSnapshot) -> DomainMatch3DTraySlot { + DomainMatch3DTraySlot { + slot_index: slot.slot_index, + item_instance_id: slot.item_instance_id.clone(), + item_type_id: slot.item_type_id.clone(), + visual_key: slot.visual_key.clone(), + } +} + +fn domain_status_to_text(status: DomainMatch3DRunStatus) -> &'static str { + match status { + DomainMatch3DRunStatus::Running => MATCH3D_RUN_RUNNING, + DomainMatch3DRunStatus::Won => MATCH3D_RUN_WON, + DomainMatch3DRunStatus::Failed => MATCH3D_RUN_FAILED, + DomainMatch3DRunStatus::Stopped => MATCH3D_RUN_STOPPED, + } +} + +fn domain_status_from_text(value: &str) -> DomainMatch3DRunStatus { + match value { + MATCH3D_RUN_WON | "won" => DomainMatch3DRunStatus::Won, + MATCH3D_RUN_FAILED | "failed" => DomainMatch3DRunStatus::Failed, + MATCH3D_RUN_STOPPED | "stopped" => DomainMatch3DRunStatus::Stopped, + _ => DomainMatch3DRunStatus::Running, + } +} + +fn domain_failure_to_text(reason: DomainMatch3DFailureReason) -> &'static str { + match reason { + DomainMatch3DFailureReason::TimeUp => MATCH3D_FAILURE_TIME_UP, + DomainMatch3DFailureReason::TrayFull => MATCH3D_FAILURE_TRAY_FULL, + } +} + +fn domain_failure_from_text(value: &str) -> DomainMatch3DFailureReason { + match value { + MATCH3D_FAILURE_TRAY_FULL | "tray_full" => DomainMatch3DFailureReason::TrayFull, + _ => DomainMatch3DFailureReason::TimeUp, + } +} + +fn domain_item_state_to_text(state: DomainMatch3DItemState) -> &'static str { + match state { + DomainMatch3DItemState::InBoard => MATCH3D_ITEM_IN_BOARD, + DomainMatch3DItemState::InTray => MATCH3D_ITEM_IN_TRAY, + DomainMatch3DItemState::Cleared => MATCH3D_ITEM_CLEARED, + } +} + +fn domain_item_state_from_text(value: &str) -> DomainMatch3DItemState { + match value { + MATCH3D_ITEM_IN_TRAY | "in_tray" => DomainMatch3DItemState::InTray, + MATCH3D_ITEM_CLEARED | "cleared" => DomainMatch3DItemState::Cleared, + _ => DomainMatch3DItemState::InBoard, + } +} + +fn deterministic_run_seed( + run_id: &str, + profile_id: &str, + clear_count: u32, + difficulty: u32, +) -> u64 { + let mut seed = 0xcbf2_9ce4_8422_2325_u64; + for byte in run_id.bytes().chain(profile_id.bytes()) { + seed ^= byte as u64; + seed = seed.wrapping_mul(0x0000_0100_0000_01b3); + } + seed ^ ((clear_count as u64) << 32) ^ difficulty as u64 +} + +fn to_u64_ms(value: i64) -> u64 { + value.max(0) as u64 +} + +fn compute_remaining_ms(snapshot: &Match3DRunSnapshot, server_now_ms: i64) -> i64 { + snapshot + .duration_limit_ms + .saturating_sub(server_now_ms.saturating_sub(snapshot.started_at_ms)) + .max(0) +} + +fn current_server_ms(ctx: &ReducerContext) -> i64 { + ctx.timestamp + .to_micros_since_unix_epoch() + .saturating_div(1000) +} + +fn clean_optional(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn clean_string(value: &str, fallback: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed.to_string() + } +} + +fn empty_to_none(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn require_non_empty(value: &str, label: &str) -> Result<(), String> { + if value.trim().is_empty() { + Err(format!("{label} 不能为空")) + } else { + Ok(()) + } +} + +fn parse_json(value: &str, label: &str) -> Result { + serde_json::from_str(value).map_err(|error| format!("{label} 非法: {error}")) +} + +fn deserialize_snapshot(value: &str) -> Result { + parse_json(value, "match3d snapshot_json") +} + +fn to_json_string(value: &T) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) +} + +fn session_result(session: Match3DAgentSessionSnapshot) -> Match3DAgentSessionProcedureResult { + Match3DAgentSessionProcedureResult { + ok: true, + session_json: Some(to_json_string(&session)), + error_message: None, + } +} + +fn session_error(message: String) -> Match3DAgentSessionProcedureResult { + Match3DAgentSessionProcedureResult { + ok: false, + session_json: None, + error_message: Some(message), + } +} + +fn work_result(work: Match3DWorkSnapshot) -> Match3DWorkProcedureResult { + Match3DWorkProcedureResult { + ok: true, + work_json: Some(to_json_string(&work)), + error_message: None, + } +} + +fn work_error(message: String) -> Match3DWorkProcedureResult { + Match3DWorkProcedureResult { + ok: false, + work_json: None, + error_message: Some(message), + } +} + +fn run_result(run: Match3DRunSnapshot) -> Match3DRunProcedureResult { + Match3DRunProcedureResult { + ok: true, + run_json: Some(to_json_string(&run)), + error_message: None, + } +} + +fn run_error(message: String) -> Match3DRunProcedureResult { + Match3DRunProcedureResult { + ok: false, + run_json: None, + error_message: Some(message), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn match3d_total_items_follow_clear_count() { + let work = Match3DWorkProfileRow { + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: "session-1".to_string(), + author_display_name: "作者".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary_text: "水果主题".to_string(), + tags_json: "[\"水果\"]".to_string(), + cover_image_src: "/cover.png".to_string(), + cover_asset_id: String::new(), + clear_count: 4, + difficulty: 3, + config_json: to_json_string(&Match3DCreatorConfigSnapshot { + theme_text: "水果".to_string(), + reference_image_src: None, + clear_count: 4, + difficulty: 3, + }), + publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(), + play_count: 0, + updated_at: Timestamp::from_micros_since_unix_epoch(1), + published_at: None, + }; + let snapshot = build_initial_run_snapshot("run-1", &work, 10); + assert_eq!(snapshot.total_item_count, 12); + assert_eq!(snapshot.items.len(), 12); + } + + #[test] + fn match3d_domain_click_bridge_clears_three_items() { + let snapshot = Match3DRunSnapshot { + run_id: "run-1".to_string(), + profile_id: "profile-1".to_string(), + status: MATCH3D_RUN_RUNNING.to_string(), + snapshot_version: 1, + started_at_ms: 0, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + server_now_ms: 0, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + clear_count: 1, + total_item_count: 3, + cleared_item_count: 0, + tray_slots: (0..MATCH3D_TRAY_SLOT_COUNT) + .map(|slot_index| Match3DTraySlotSnapshot { + slot_index, + item_instance_id: (slot_index < 2).then(|| format!("item-{slot_index}")), + item_type_id: (slot_index < 3).then(|| "type-1".to_string()), + visual_key: (slot_index < 3).then(|| "visual-1".to_string()), + }) + .collect(), + items: (0..3) + .map(|index| Match3DItemSnapshot { + item_instance_id: format!("item-{index}"), + item_type_id: "type-1".to_string(), + visual_key: "visual-1".to_string(), + x: 0.0, + y: 0.0, + radius: 0.1, + layer: index, + state: if index < 2 { + MATCH3D_ITEM_IN_TRAY.to_string() + } else { + MATCH3D_ITEM_IN_BOARD.to_string() + }, + clickable: index == 2, + }) + .collect(), + failure_reason: None, + }; + + let domain_run = domain_snapshot_from_snapshot(&snapshot, "user-1"); + let confirmation = confirm_domain_click_at( + &domain_run, + &DomainMatch3DClickInput { + run_id: "run-1".to_string(), + owner_user_id: "user-1".to_string(), + item_instance_id: "item-2".to_string(), + client_action_id: "action-1".to_string(), + snapshot_version: 1, + clicked_at_ms: 10, + }, + ) + .expect("domain click should be confirmed"); + let next = snapshot_from_domain(&confirmation.run, 10); + + assert!(confirmation.accepted); + assert_eq!(confirmation.cleared_item_instance_ids.len(), 3); + assert!( + next.tray_slots + .iter() + .all(|slot| slot.item_instance_id.is_none()) + ); + assert!( + next.items + .iter() + .all(|item| item.state == MATCH3D_ITEM_CLEARED) + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/match3d/tables.rs b/server-rs/crates/spacetime-module/src/match3d/tables.rs new file mode 100644 index 00000000..2c9ece38 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/match3d/tables.rs @@ -0,0 +1,86 @@ +use crate::*; + +#[spacetimedb::table( + accessor = match3d_agent_session, + index(accessor = by_match3d_agent_session_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct Match3DAgentSessionRow { + #[primary_key] + pub(crate) session_id: String, + pub(crate) owner_user_id: String, + pub(crate) seed_text: String, + pub(crate) current_turn: u32, + pub(crate) progress_percent: u32, + pub(crate) stage: String, + pub(crate) config_json: String, + pub(crate) draft_json: String, + pub(crate) last_assistant_reply: String, + pub(crate) published_profile_id: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = match3d_agent_message, + index(accessor = by_match3d_agent_message_session_id, btree(columns = [session_id])) +)] +pub struct Match3DAgentMessageRow { + #[primary_key] + pub(crate) message_id: String, + pub(crate) session_id: String, + pub(crate) role: String, + pub(crate) kind: String, + pub(crate) text: String, + pub(crate) created_at: Timestamp, +} + +#[spacetimedb::table( + accessor = match3d_work_profile, + index(accessor = by_match3d_work_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_match3d_work_publication_status, btree(columns = [publication_status])) +)] +pub struct Match3DWorkProfileRow { + #[primary_key] + pub(crate) profile_id: String, + pub(crate) owner_user_id: String, + pub(crate) source_session_id: String, + pub(crate) author_display_name: String, + pub(crate) game_name: String, + pub(crate) theme_text: String, + pub(crate) summary_text: String, + pub(crate) tags_json: String, + pub(crate) cover_image_src: String, + pub(crate) cover_asset_id: String, + pub(crate) clear_count: u32, + pub(crate) difficulty: u32, + pub(crate) config_json: String, + pub(crate) publication_status: String, + pub(crate) play_count: u32, + pub(crate) updated_at: Timestamp, + pub(crate) published_at: Option, +} + +#[spacetimedb::table( + accessor = match3d_runtime_run, + index(accessor = by_match3d_run_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_match3d_run_profile_id, btree(columns = [profile_id])) +)] +pub struct Match3DRuntimeRunRow { + #[primary_key] + pub(crate) run_id: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) status: String, + pub(crate) snapshot_version: u32, + pub(crate) started_at_ms: i64, + pub(crate) duration_limit_ms: i64, + pub(crate) finished_at_ms: i64, + pub(crate) elapsed_ms: i64, + pub(crate) clear_count: u32, + pub(crate) total_item_count: u32, + pub(crate) cleared_item_count: u32, + pub(crate) failure_reason: String, + pub(crate) snapshot_json: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} diff --git a/server-rs/crates/spacetime-module/src/match3d/types.rs b/server-rs/crates/spacetime-module/src/match3d/types.rs new file mode 100644 index 00000000..17d6dbf2 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/match3d/types.rs @@ -0,0 +1,332 @@ +use crate::*; +use serde::{Deserialize, Serialize}; + +pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: i64 = 600_000; +pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7; +pub const MATCH3D_VISUAL_VARIANT_COUNT: u32 = 10; +pub const MATCH3D_MIN_DIFFICULTY: u32 = 1; +pub const MATCH3D_MAX_DIFFICULTY: u32 = 10; + +pub const MATCH3D_STAGE_COLLECTING: &str = "Collecting"; +pub const MATCH3D_STAGE_READY_TO_COMPILE: &str = "ReadyToCompile"; +pub const MATCH3D_STAGE_DRAFT_COMPILED: &str = "DraftCompiled"; +pub const MATCH3D_STAGE_PUBLISHED: &str = "Published"; + +pub const MATCH3D_ROLE_USER: &str = "user"; +pub const MATCH3D_ROLE_ASSISTANT: &str = "assistant"; +pub const MATCH3D_KIND_TEXT: &str = "text"; + +pub const MATCH3D_PUBLICATION_DRAFT: &str = "Draft"; +pub const MATCH3D_PUBLICATION_PUBLISHED: &str = "Published"; + +pub const MATCH3D_RUN_RUNNING: &str = "Running"; +pub const MATCH3D_RUN_WON: &str = "Won"; +pub const MATCH3D_RUN_FAILED: &str = "Failed"; +pub const MATCH3D_RUN_STOPPED: &str = "Stopped"; + +pub const MATCH3D_FAILURE_TIME_UP: &str = "TimeUp"; +pub const MATCH3D_FAILURE_TRAY_FULL: &str = "TrayFull"; + +pub const MATCH3D_CLICK_ACCEPTED: &str = "Accepted"; +pub const MATCH3D_CLICK_REJECTED_NOT_CLICKABLE: &str = "RejectedNotClickable"; +pub const MATCH3D_CLICK_REJECTED_ALREADY_MOVED: &str = "RejectedAlreadyMoved"; +pub const MATCH3D_CLICK_REJECTED_TRAY_FULL: &str = "RejectedTrayFull"; +pub const MATCH3D_CLICK_VERSION_CONFLICT: &str = "VersionConflict"; +pub const MATCH3D_CLICK_RUN_FINISHED: &str = "RunFinished"; + +pub const MATCH3D_ITEM_IN_BOARD: &str = "InBoard"; +pub const MATCH3D_ITEM_IN_TRAY: &str = "InTray"; +pub const MATCH3D_ITEM_CLEARED: &str = "Cleared"; + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentSessionCreateInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub config_json: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentSessionGetInput { + pub session_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentMessageSubmitInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentMessageFinalizeInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub config_json: Option, + pub progress_percent: u32, + pub stage: String, + pub updated_at_micros: i64, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DDraftCompileInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub author_display_name: String, + pub game_name: Option, + pub summary_text: Option, + pub tags_json: Option, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub compiled_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkUpdateInput { + pub profile_id: String, + pub owner_user_id: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags_json: String, + pub cover_image_src: String, + pub cover_asset_id: String, + pub clear_count: u32, + pub difficulty: u32, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkPublishInput { + pub profile_id: String, + pub owner_user_id: String, + pub published_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorksListInput { + pub owner_user_id: String, + pub published_only: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkGetInput { + pub profile_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkDeleteInput { + pub profile_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunStartInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub started_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunGetInput { + pub run_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunClickInput { + pub run_id: String, + pub owner_user_id: String, + pub item_instance_id: String, + pub client_snapshot_version: u32, + pub client_event_id: String, + pub clicked_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunStopInput { + pub run_id: String, + pub owner_user_id: String, + pub stopped_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunRestartInput { + pub source_run_id: String, + pub next_run_id: String, + pub owner_user_id: String, + pub restarted_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunTimeUpInput { + pub run_id: String, + pub owner_user_id: String, + pub finished_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentSessionProcedureResult { + pub ok: bool, + pub session_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkProcedureResult { + pub ok: bool, + pub work_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorksProcedureResult { + pub ok: bool, + pub items_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunProcedureResult { + pub ok: bool, + pub run_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DClickItemProcedureResult { + pub ok: bool, + pub status: String, + pub run_json: Option, + pub accepted_item_instance_id: Option, + pub cleared_item_instance_ids: Vec, + pub failure_reason: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DCreatorConfigSnapshot { + pub theme_text: String, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentMessageSnapshot { + pub message_id: String, + pub session_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DDraftSnapshot { + pub profile_id: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags: Vec, + pub clear_count: u32, + pub difficulty: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentSessionSnapshot { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub config: Match3DCreatorConfigSnapshot, + pub draft: Option, + pub messages: Vec, + pub last_assistant_reply: String, + pub published_profile_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkSnapshot { + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags: Vec, + pub cover_image_src: String, + pub cover_asset_id: String, + pub clear_count: u32, + pub difficulty: u32, + pub config: Match3DCreatorConfigSnapshot, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DItemSnapshot { + pub item_instance_id: String, + pub item_type_id: String, + pub visual_key: String, + pub x: f32, + pub y: f32, + pub radius: f32, + pub layer: u32, + pub state: String, + pub clickable: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DTraySlotSnapshot { + pub slot_index: u32, + pub item_instance_id: Option, + pub item_type_id: Option, + pub visual_key: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DRunSnapshot { + pub run_id: String, + pub profile_id: String, + pub status: String, + pub snapshot_version: u32, + pub started_at_ms: i64, + pub duration_limit_ms: i64, + pub server_now_ms: i64, + pub remaining_ms: i64, + pub clear_count: u32, + pub total_item_count: u32, + pub cleared_item_count: u32, + pub tray_slots: Vec, + pub items: Vec, + pub failure_reason: Option, +} diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index f19a74a1..bfa4ccab 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -5,6 +5,9 @@ use spacetimedb_lib::sats::ser::serde::SerializeWrapper; use std::collections::HashSet; use crate::big_fish::big_fish_runtime_run; +use crate::match3d::tables::{ + match3d_agent_message, match3d_agent_session, match3d_runtime_run, match3d_work_profile, +}; use crate::puzzle::{ puzzle_agent_message, puzzle_agent_session, puzzle_event, puzzle_leaderboard_entry, puzzle_runtime_run, puzzle_work_profile, @@ -12,6 +15,8 @@ use crate::puzzle::{ const MIGRATION_SCHEMA_VERSION: u32 = 1; const MIGRATION_MAX_TABLE_NAME_LEN: usize = 96; +const MIGRATION_MAX_IMPORT_UPLOAD_ID_LEN: usize = 128; +const MIGRATION_MAX_IMPORT_CHUNK_BYTES: usize = 1024 * 1024; const MIGRATION_MAX_OPERATOR_NOTE_CHARS: usize = 160; const MIGRATION_MIN_BOOTSTRAP_SECRET_LEN: usize = 16; const MIGRATION_BOOTSTRAP_SECRET: Option<&str> = @@ -26,6 +31,21 @@ pub struct DatabaseMigrationOperator { pub note: String, } +#[spacetimedb::table( + accessor = database_migration_import_chunk, + index(accessor = by_database_migration_import_upload, btree(columns = [upload_id])) +)] +pub struct DatabaseMigrationImportChunk { + #[primary_key] + pub chunk_key: String, + pub upload_id: String, + pub chunk_index: u32, + pub chunk_count: u32, + pub operator_identity: Identity, + pub created_at: Timestamp, + pub chunk: String, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct DatabaseMigrationExportInput { pub include_tables: Vec, @@ -39,6 +59,27 @@ pub struct DatabaseMigrationImportInput { pub dry_run: bool, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct DatabaseMigrationImportChunkInput { + pub upload_id: String, + pub chunk_index: u32, + pub chunk_count: u32, + pub chunk: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct DatabaseMigrationImportChunksInput { + pub upload_id: String, + pub include_tables: Vec, + pub replace_existing: bool, + pub dry_run: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct DatabaseMigrationImportChunksClearInput { + pub upload_id: String, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum DatabaseMigrationImportMode { Strict, @@ -65,12 +106,20 @@ pub struct DatabaseMigrationTableStat { pub skipped_row_count: u64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct DatabaseMigrationWarning { + pub table_name: String, + pub warning_kind: String, + pub message: String, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct DatabaseMigrationProcedureResult { pub ok: bool, pub schema_version: u32, pub migration_json: Option, pub table_stats: Vec, + pub warnings: Vec, pub error_message: Option, } @@ -117,6 +166,8 @@ macro_rules! migration_tables { profile_invite_code, profile_referral_relation, profile_played_world, + public_work_play_daily_stat, + public_work_like, profile_membership, profile_recharge_order, profile_save_archive, @@ -146,6 +197,10 @@ macro_rules! migration_tables { puzzle_event, puzzle_runtime_run, puzzle_leaderboard_entry, + match3d_agent_session, + match3d_agent_message, + match3d_work_profile, + match3d_runtime_run, big_fish_creation_session, big_fish_agent_message, big_fish_asset_slot, @@ -249,6 +304,7 @@ pub fn export_database_migration_to_file( schema_version: MIGRATION_SCHEMA_VERSION, migration_json: Some(migration_json), table_stats: stats, + warnings: Vec::new(), error_message: None, }, Err(error) => DatabaseMigrationProcedureResult { @@ -256,6 +312,7 @@ pub fn export_database_migration_to_file( schema_version: MIGRATION_SCHEMA_VERSION, migration_json: None, table_stats: Vec::new(), + warnings: Vec::new(), error_message: Some(error), }, } @@ -269,11 +326,12 @@ pub fn import_database_migration_from_file( ) -> DatabaseMigrationProcedureResult { match import_database_migration_from_file_inner(ctx, input, DatabaseMigrationImportMode::Strict) { - Ok(stats) => DatabaseMigrationProcedureResult { + Ok((stats, warnings)) => DatabaseMigrationProcedureResult { ok: true, schema_version: MIGRATION_SCHEMA_VERSION, migration_json: None, table_stats: stats, + warnings, error_message: None, }, Err(error) => DatabaseMigrationProcedureResult { @@ -281,6 +339,7 @@ pub fn import_database_migration_from_file( schema_version: MIGRATION_SCHEMA_VERSION, migration_json: None, table_stats: Vec::new(), + warnings: Vec::new(), error_message: Some(error), }, } @@ -297,11 +356,12 @@ pub fn import_database_migration_incremental_from_file( input, DatabaseMigrationImportMode::Incremental, ) { - Ok(stats) => DatabaseMigrationProcedureResult { + Ok((stats, warnings)) => DatabaseMigrationProcedureResult { ok: true, schema_version: MIGRATION_SCHEMA_VERSION, migration_json: None, table_stats: stats, + warnings, error_message: None, }, Err(error) => DatabaseMigrationProcedureResult { @@ -309,11 +369,82 @@ pub fn import_database_migration_incremental_from_file( schema_version: MIGRATION_SCHEMA_VERSION, migration_json: None, table_stats: Vec::new(), + warnings: Vec::new(), error_message: Some(error), }, } } +// 大迁移 JSON 先按分片写入私有临时表,避免单次 HTTP request body 触发 SpacetimeDB 413。 +#[spacetimedb::procedure] +pub fn put_database_migration_import_chunk( + ctx: &mut ProcedureContext, + input: DatabaseMigrationImportChunkInput, +) -> DatabaseMigrationProcedureResult { + match put_database_migration_import_chunk_inner(ctx, input) { + Ok(()) => empty_database_migration_result(true, None), + Err(error) => empty_database_migration_result(false, Some(error)), + } +} + +// 分片提交保持与直接导入相同的严格追加语义;提交成功后清理临时分片。 +#[spacetimedb::procedure] +pub fn import_database_migration_from_chunks( + ctx: &mut ProcedureContext, + input: DatabaseMigrationImportChunksInput, +) -> DatabaseMigrationProcedureResult { + match import_database_migration_from_chunks_inner( + ctx, + input, + DatabaseMigrationImportMode::Strict, + ) { + Ok((stats, warnings)) => DatabaseMigrationProcedureResult { + ok: true, + schema_version: MIGRATION_SCHEMA_VERSION, + migration_json: None, + table_stats: stats, + warnings, + error_message: None, + }, + Err(error) => empty_database_migration_result(false, Some(error)), + } +} + +// 分片增量提交只插入目标库缺失的行;主键或唯一约束冲突的行会跳过。 +#[spacetimedb::procedure] +pub fn import_database_migration_incremental_from_chunks( + ctx: &mut ProcedureContext, + input: DatabaseMigrationImportChunksInput, +) -> DatabaseMigrationProcedureResult { + match import_database_migration_from_chunks_inner( + ctx, + input, + DatabaseMigrationImportMode::Incremental, + ) { + Ok((stats, warnings)) => DatabaseMigrationProcedureResult { + ok: true, + schema_version: MIGRATION_SCHEMA_VERSION, + migration_json: None, + table_stats: stats, + warnings, + error_message: None, + }, + Err(error) => empty_database_migration_result(false, Some(error)), + } +} + +// 调用方上传失败或提交失败时可显式清理同一 upload_id 的临时分片。 +#[spacetimedb::procedure] +pub fn clear_database_migration_import_chunks( + ctx: &mut ProcedureContext, + input: DatabaseMigrationImportChunksClearInput, +) -> DatabaseMigrationProcedureResult { + match clear_database_migration_import_chunks_inner(ctx, input) { + Ok(()) => empty_database_migration_result(true, None), + Err(error) => empty_database_migration_result(false, Some(error)), + } +} + fn export_database_migration_to_file_inner( ctx: &mut ProcedureContext, input: DatabaseMigrationExportInput, @@ -337,7 +468,13 @@ fn import_database_migration_from_file_inner( ctx: &mut ProcedureContext, input: DatabaseMigrationImportInput, import_mode: DatabaseMigrationImportMode, -) -> Result, String> { +) -> Result< + ( + Vec, + Vec, + ), + String, +> { let caller = ctx.sender(); let included_tables = normalize_include_tables(&input.include_tables)?; if import_mode == DatabaseMigrationImportMode::Incremental && input.replace_existing { @@ -348,16 +485,9 @@ fn import_database_migration_from_file_inner( } ctx.try_with_tx(|tx| require_migration_operator(tx, caller))?; - let migration_file = serde_json::from_str::(&input.migration_json) - .map_err(|error| format!("迁移文件 JSON 解析失败: {error}"))?; - if migration_file.schema_version != MIGRATION_SCHEMA_VERSION { - return Err(format!( - "迁移文件 schema_version 不匹配,期望 {},实际 {}", - MIGRATION_SCHEMA_VERSION, migration_file.schema_version - )); - } + let migration_file = parse_migration_file(&input.migration_json)?; - let stats = if input.dry_run { + let (stats, warnings) = if input.dry_run { build_import_dry_run_stats(&migration_file.tables, included_tables.as_ref())? } else { ctx.try_with_tx(|tx| { @@ -372,7 +502,159 @@ fn import_database_migration_from_file_inner( })? }; - Ok(stats) + Ok((stats, warnings)) +} + +fn put_database_migration_import_chunk_inner( + ctx: &mut ProcedureContext, + input: DatabaseMigrationImportChunkInput, +) -> Result<(), String> { + let caller = ctx.sender(); + let upload_id = normalize_import_upload_id(&input.upload_id)?; + if input.chunk_count == 0 { + return Err("分片总数必须大于 0".to_string()); + } + if input.chunk_index >= input.chunk_count { + return Err(format!( + "分片序号越界: {} / {}", + input.chunk_index, input.chunk_count + )); + } + if input.chunk.is_empty() { + return Err("迁移 JSON 分片不能为空".to_string()); + } + if input.chunk.len() > MIGRATION_MAX_IMPORT_CHUNK_BYTES { + return Err(format!( + "迁移 JSON 分片过大,单片最多 {} bytes", + MIGRATION_MAX_IMPORT_CHUNK_BYTES + )); + } + + let chunk_key = build_import_chunk_key(&upload_id, input.chunk_index); + ctx.try_with_tx(|tx| { + require_migration_operator(tx, caller)?; + if let Some(existing) = tx + .db + .database_migration_import_chunk() + .chunk_key() + .find(&chunk_key) + { + if existing.operator_identity != caller { + return Err("同名迁移分片已由其他 identity 上传,已拒绝覆盖".to_string()); + } + tx.db + .database_migration_import_chunk() + .chunk_key() + .delete(&chunk_key); + } + tx.db + .database_migration_import_chunk() + .insert(DatabaseMigrationImportChunk { + chunk_key: chunk_key.clone(), + upload_id: upload_id.clone(), + chunk_index: input.chunk_index, + chunk_count: input.chunk_count, + operator_identity: caller, + created_at: tx.timestamp, + chunk: input.chunk.clone(), + }); + Ok(()) + })?; + + Ok(()) +} + +fn import_database_migration_from_chunks_inner( + ctx: &mut ProcedureContext, + input: DatabaseMigrationImportChunksInput, + import_mode: DatabaseMigrationImportMode, +) -> Result< + ( + Vec, + Vec, + ), + String, +> { + let caller = ctx.sender(); + let upload_id = normalize_import_upload_id(&input.upload_id)?; + let included_tables = normalize_include_tables(&input.include_tables)?; + if import_mode == DatabaseMigrationImportMode::Incremental && input.replace_existing { + return Err("增量导入不能同时启用 replace_existing".to_string()); + } + + let migration_json = ctx.try_with_tx(|tx| { + require_migration_operator(tx, caller)?; + read_database_migration_import_chunks(tx, &upload_id, caller) + })?; + let migration_file = parse_migration_file(&migration_json)?; + + let (stats, warnings) = if input.dry_run { + build_import_dry_run_stats(&migration_file.tables, included_tables.as_ref())? + } else { + ctx.try_with_tx(|tx| { + require_migration_operator(tx, caller)?; + apply_migration_file( + tx, + &migration_file, + included_tables.as_ref(), + input.replace_existing, + import_mode, + ) + })? + }; + + ctx.try_with_tx(|tx| { + require_migration_operator(tx, caller)?; + clear_database_migration_import_chunks_tx(tx, &upload_id); + Ok::<(), String>(()) + })?; + + Ok((stats, warnings)) +} + +fn clear_database_migration_import_chunks_inner( + ctx: &mut ProcedureContext, + input: DatabaseMigrationImportChunksClearInput, +) -> Result<(), String> { + let caller = ctx.sender(); + let upload_id = normalize_import_upload_id(&input.upload_id)?; + ctx.try_with_tx(|tx| { + require_migration_operator(tx, caller)?; + clear_database_migration_import_chunks_tx(tx, &upload_id); + Ok::<(), String>(()) + })?; + Ok(()) +} + +fn empty_database_migration_result( + ok: bool, + error_message: Option, +) -> DatabaseMigrationProcedureResult { + DatabaseMigrationProcedureResult { + ok, + schema_version: MIGRATION_SCHEMA_VERSION, + migration_json: None, + table_stats: Vec::new(), + warnings: Vec::new(), + error_message, + } +} + +fn parse_migration_file(migration_json: &str) -> Result { + if migration_json.trim().is_empty() { + return Err("migration_json 不能为空".to_string()); + } + + let migration_file = serde_json::from_str::(migration_json) + .map_err(|error| format!("迁移文件 JSON 解析失败: {error}"))?; + if migration_file.schema_version != MIGRATION_SCHEMA_VERSION { + return Err(format!( + "迁移文件 schema_version 不匹配,期望 {},实际 {}", + MIGRATION_SCHEMA_VERSION, migration_file.schema_version + )); + } + + Ok(migration_file) } fn authorize_database_migration_operator_inner( @@ -516,6 +798,96 @@ fn normalize_migration_operator_note(input: &str) -> Result { Ok(note.to_string()) } +fn normalize_import_upload_id(input: &str) -> Result { + let upload_id = input.trim(); + if upload_id.is_empty() { + return Err("upload_id 不能为空".to_string()); + } + if upload_id.len() > MIGRATION_MAX_IMPORT_UPLOAD_ID_LEN { + return Err(format!( + "upload_id 过长,最多 {} bytes", + MIGRATION_MAX_IMPORT_UPLOAD_ID_LEN + )); + } + if !upload_id + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + { + return Err("upload_id 只能使用 ASCII 字母、数字、短横线或下划线".to_string()); + } + Ok(upload_id.to_string()) +} + +fn build_import_chunk_key(upload_id: &str, chunk_index: u32) -> String { + format!("{upload_id}:{chunk_index:010}") +} + +fn read_database_migration_import_chunks( + ctx: &ReducerContext, + upload_id: &str, + caller: Identity, +) -> Result { + let mut chunks = ctx + .db + .database_migration_import_chunk() + .by_database_migration_import_upload() + .filter(upload_id) + .collect::>(); + if chunks.is_empty() { + return Err(format!("未找到迁移 JSON 分片: {upload_id}")); + } + if chunks.iter().any(|chunk| chunk.operator_identity != caller) { + return Err("迁移 JSON 分片包含其他 identity 上传的片段,已拒绝提交".to_string()); + } + + let chunk_count = chunks[0].chunk_count; + if chunk_count == 0 { + return Err("迁移 JSON 分片总数不合法".to_string()); + } + if chunks + .iter() + .any(|chunk| chunk.chunk_count != chunk_count || chunk.upload_id != upload_id) + { + return Err("迁移 JSON 分片总数不一致".to_string()); + } + if chunks.len() != chunk_count as usize { + return Err(format!( + "迁移 JSON 分片未上传完整,已收到 {} / {}", + chunks.len(), + chunk_count + )); + } + + chunks.sort_by_key(|chunk| chunk.chunk_index); + let mut expected_index = 0u32; + let mut migration_json = String::new(); + for chunk in chunks { + if chunk.chunk_index != expected_index { + return Err(format!("迁移 JSON 分片缺失序号: {expected_index}")); + } + migration_json.push_str(&chunk.chunk); + expected_index = expected_index.saturating_add(1); + } + + Ok(migration_json) +} + +fn clear_database_migration_import_chunks_tx(ctx: &ReducerContext, upload_id: &str) { + let chunk_keys = ctx + .db + .database_migration_import_chunk() + .by_database_migration_import_upload() + .filter(upload_id) + .map(|chunk| chunk.chunk_key) + .collect::>(); + for chunk_key in chunk_keys { + ctx.db + .database_migration_import_chunk() + .chunk_key() + .delete(&chunk_key); + } +} + fn normalize_include_tables(input: &[String]) -> Result>, String> { if input.is_empty() { return Ok(None); @@ -574,11 +946,25 @@ fn build_export_stats(tables: &[MigrationTable]) -> Vec>, -) -> Result, String> { +) -> Result< + ( + Vec, + Vec, + ), + String, +> { let mut stats = Vec::new(); + let mut warnings = Vec::new(); for table in tables { if !is_supported_migration_table(&table.name) { - return Err(format!("迁移文件包含不支持的表: {}", table.name)); + warnings.push(build_dropped_table_warning(table)); + stats.push(DatabaseMigrationTableStat { + table_name: table.name.clone(), + exported_row_count: 0, + imported_row_count: 0, + skipped_row_count: table.rows.len() as u64, + }); + continue; } if should_include_table(include_tables, &table.name) { stats.push(DatabaseMigrationTableStat { @@ -596,7 +982,7 @@ fn build_import_dry_run_stats( }); } } - Ok(stats) + Ok((stats, warnings)) } fn apply_migration_file( @@ -605,13 +991,15 @@ fn apply_migration_file( include_tables: Option<&HashSet>, replace_existing: bool, import_mode: DatabaseMigrationImportMode, -) -> Result, String> { +) -> Result< + ( + Vec, + Vec, + ), + String, +> { let mut stats = Vec::new(); - for table in &migration_file.tables { - if !is_supported_migration_table(&table.name) { - return Err(format!("迁移文件包含不支持的表: {}", table.name)); - } - } + let mut warnings = Vec::new(); let import_table_names = build_import_table_name_set(migration_file, include_tables); if replace_existing { @@ -620,6 +1008,17 @@ fn apply_migration_file( } for table in &migration_file.tables { + if !is_supported_migration_table(&table.name) { + warnings.push(build_dropped_table_warning(table)); + stats.push(DatabaseMigrationTableStat { + table_name: table.name.clone(), + exported_row_count: 0, + imported_row_count: 0, + skipped_row_count: table.rows.len() as u64, + }); + continue; + } + if !should_include_table(include_tables, &table.name) { stats.push(DatabaseMigrationTableStat { table_name: table.name.clone(), @@ -631,7 +1030,7 @@ fn apply_migration_file( } let (imported_row_count, skipped_row_count) = - insert_migration_table_rows(ctx, table, import_mode)?; + insert_migration_table_rows(ctx, table, import_mode, &mut warnings)?; stats.push(DatabaseMigrationTableStat { table_name: table.name.clone(), exported_row_count: 0, @@ -640,7 +1039,7 @@ fn apply_migration_file( }); } - Ok(stats) + Ok((stats, warnings)) } fn build_import_table_name_set( @@ -655,37 +1054,192 @@ fn build_import_table_name_set( .collect() } +fn build_dropped_table_warning(table: &MigrationTable) -> DatabaseMigrationWarning { + DatabaseMigrationWarning { + table_name: table.name.clone(), + warning_kind: "dropped_table".to_string(), + message: format!( + "迁移文件包含当前模块已删除或未加入白名单的表 {},已跳过 {} 行", + table.name, + table.rows.len() + ), + } +} + +fn build_dropped_field_warning(table_name: &str, field_name: &str) -> DatabaseMigrationWarning { + DatabaseMigrationWarning { + table_name: table_name.to_string(), + warning_kind: "dropped_field".to_string(), + message: format!("表 {table_name} 的旧字段 {field_name} 当前已不存在,已在导入时丢弃"), + } +} + fn row_to_json(row: &T) -> Result { serde_json::to_value(SerializeWrapper::from_ref(row)) .map_err(|error| format!("迁移行序列化失败: {error}")) } -fn row_from_json(value: &serde_json::Value) -> Result +fn row_from_json( + table_name: &str, + value: &serde_json::Value, + warnings: &mut Vec, +) -> Result where T: for<'de> spacetimedb::Deserialize<'de>, { - let wrapped: DeserializeWrapper = serde_json::from_value(value.clone()) - .map_err(|error| format!("迁移行反序列化失败: {error}"))?; + let wrapped = match serde_json::from_value::>(value.clone()) { + Ok(row) => row, + Err(original_error) => recover_row_with_deleted_fields::( + table_name, + value, + &original_error.to_string(), + warnings, + ) + .ok_or_else(|| format!("迁移行反序列化失败,且无法通过丢弃旧字段恢复: {original_error}"))?, + }; Ok(wrapped.0) } fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value { let mut next_value = value.clone(); + if table_name == "user_account" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:头像字段晚于认证拆表加入,旧迁移包按未设置头像兼容。 + object + .entry("avatar_url".to_string()) + .or_insert(serde_json::Value::Null); + } + } + if table_name == "profile_invite_code" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:邀请码 metadata 晚于邀请表加入,旧迁移包按空对象兼容。 + object + .entry("metadata_json".to_string()) + .or_insert_with(|| serde_json::Value::String("{}".to_string())); + } + } if table_name == "big_fish_creation_session" { if let Some(object) = next_value.as_object_mut() { // 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。 object .entry("play_count".to_string()) .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("remix_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("like_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("published_at".to_string()) + .or_insert(serde_json::Value::Null); + } + } + if table_name == "custom_world_profile" || table_name == "custom_world_gallery_entry" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:自定义世界公开互动计数字段晚于基础作品表加入,旧迁移包按 0 兼容。 + object + .entry("play_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("remix_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("like_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + } + } + if table_name == "puzzle_work_profile" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:拼图公开互动计数晚于基础作品表加入,旧迁移包按 0 兼容。 + object + .entry("play_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("remix_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("like_count".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("point_incentive_total_half_points".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("point_incentive_claimed_points".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + // 中文注释:拼图多关卡字段晚于旧作品表加入,旧迁移包留空并由读取层补出首关。 + object + .entry("levels_json".to_string()) + .or_insert_with(|| serde_json::Value::from("")); + // 中文注释:作品名称/描述从旧关卡名/画面摘要拆出,旧行保留旧值做兼容回填。 + let fallback_title = object + .get("level_name") + .cloned() + .unwrap_or_else(|| serde_json::Value::from("")); + object + .entry("work_title".to_string()) + .or_insert(fallback_title); + let fallback_description = object + .get("summary") + .cloned() + .unwrap_or_else(|| serde_json::Value::from("")); + object + .entry("work_description".to_string()) + .or_insert(fallback_description); } } next_value } +fn recover_row_with_deleted_fields( + table_name: &str, + value: &serde_json::Value, + error_message: &str, + warnings: &mut Vec, +) -> Option> +where + T: for<'de> spacetimedb::Deserialize<'de>, +{ + let mut candidate = value.as_object()?.clone(); + let mut next_error = error_message.to_string(); + + loop { + let field_name = extract_unknown_field_name(&next_error)?; + candidate.remove(&field_name)?; + warnings.push(build_dropped_field_warning(table_name, &field_name)); + + match serde_json::from_value::>(serde_json::Value::Object( + candidate.clone(), + )) { + Ok(row) => return Some(row), + Err(error) => next_error = error.to_string(), + } + } +} + +fn extract_unknown_field_name(error_message: &str) -> Option { + let marker = "unknown field"; + let marker_index = error_message.find(marker)?; + let after_marker = error_message[marker_index + marker.len()..].trim_start(); + + for quote in ['`', '"', '\''] { + if let Some(rest) = after_marker.strip_prefix(quote) { + let end_index = rest.find(quote)?; + return Some(rest[..end_index].to_string()); + } + } + + after_marker + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .find(|value| !value.is_empty()) + .map(str::to_string) +} + fn insert_migration_table_rows( ctx: &ReducerContext, table: &MigrationTable, import_mode: DatabaseMigrationImportMode, + warnings: &mut Vec, ) -> Result<(u64, u64), String> { macro_rules! insert_table_match_arm { ($($table:ident),+ $(,)?) => { @@ -696,7 +1250,7 @@ fn insert_migration_table_rows( let mut skipped = 0u64; for value in &table.rows { let normalized_value = normalize_migration_row(stringify!($table), value); - let row = row_from_json(&normalized_value) + let row = row_from_json(stringify!($table), &normalized_value, warnings) .map_err(|error| format!("{}: {error}", stringify!($table)))?; let insert_result = ctx.db .$table() diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 6e569734..6df724e3 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1,25 +1,38 @@ use crate::runtime::{ - ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work, + ProfilePlayedWorkUpsertInput, ProfileSaveArchiveUpsertInput, PublicWorkLikeRecordInput, + PublicWorkPlayRecordInput, add_profile_observed_play_time, count_recent_public_work_plays, + grant_profile_wallet_points, record_public_work_like, record_public_work_play, + upsert_profile_played_work, upsert_profile_save_archive, }; use module_puzzle::{ - PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind, + PUZZLE_MAX_TAG_COUNT, PUZZLE_NEXT_LEVEL_MODE_NONE, PUZZLE_NEXT_LEVEL_MODE_SAME_WORK, + PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind, PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, - PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate, - PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, - PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput, - PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, - PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, - PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, - PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, - apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview, - compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags, - publish_work_profile, resolve_puzzle_grid_size, select_next_profile, start_run, swap_pieces, + PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput, + PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, + PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput, + PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput, + PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput, + PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, + PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput, + PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput, + PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput, + PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, + apply_selected_candidate, build_form_draft_from_seed, build_result_preview, + compile_result_draft_from_seed, create_work_profile, infer_anchor_pack, normalize_puzzle_draft, + normalize_puzzle_levels, normalize_theme_tags, publish_work_profile, replace_puzzle_level, + select_next_profiles, selected_profile_level_after_runtime_level, selected_puzzle_level, + tag_similarity_score, }; +use module_runtime::RuntimeProfileWalletLedgerSourceType; use serde_json::from_str as json_from_str; +use serde_json::json; use serde_json::to_string as json_to_string; use spacetimedb::{ProcedureContext, SpacetimeType, Table, Timestamp, TxContext}; +const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0; + /// 拼图 Agent session 真相表。 /// 当前只保存结构化字段与 JSON 草稿,不提前拆出更多编辑态子表。 #[spacetimedb::table( @@ -70,11 +83,14 @@ pub struct PuzzleWorkProfileRow { owner_user_id: String, source_session_id: Option, author_display_name: String, + work_title: String, + work_description: String, level_name: String, summary: String, theme_tags_json: String, cover_image_src: Option, cover_asset_id: Option, + levels_json: String, publication_status: PuzzlePublicationStatus, play_count: u32, anchor_pack_json: String, @@ -82,6 +98,14 @@ pub struct PuzzleWorkProfileRow { created_at: Timestamp, updated_at: Timestamp, published_at: Option, + #[default(0)] + remix_count: u32, + #[default(0)] + like_count: u32, + #[default(PUZZLE_POINT_INCENTIVE_DEFAULT_U64)] + point_incentive_total_half_points: u64, + #[default(PUZZLE_POINT_INCENTIVE_DEFAULT_U64)] + point_incentive_claimed_points: u64, } /// 拼图创作事件类型。 @@ -246,6 +270,27 @@ pub fn compile_puzzle_agent_draft( } } +/// 保存拼图入口表单草稿。 +/// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。 +#[spacetimedb::procedure] +pub fn save_puzzle_form_draft( + ctx: &mut ProcedureContext, + input: PuzzleFormDraftSaveInput, +) -> PuzzleAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| save_puzzle_form_draft_tx(tx, input.clone())) { + Ok(session) => PuzzleAgentSessionProcedureResult { + ok: true, + session_json: Some(serialize_json(&session)), + error_message: None, + }, + Err(message) => PuzzleAgentSessionProcedureResult { + ok: false, + session_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn save_puzzle_generated_images( ctx: &mut ProcedureContext, @@ -414,6 +459,44 @@ pub fn get_puzzle_gallery_detail( } } +#[spacetimedb::procedure] +pub fn record_puzzle_work_like( + ctx: &mut ProcedureContext, + input: PuzzleWorkLikeInput, +) -> PuzzleWorkProcedureResult { + match ctx.try_with_tx(|tx| record_puzzle_work_like_tx(tx, input.clone())) { + Ok(item) => PuzzleWorkProcedureResult { + ok: true, + item_json: Some(serialize_json(&item)), + error_message: None, + }, + Err(message) => PuzzleWorkProcedureResult { + ok: false, + item_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn remix_puzzle_work( + ctx: &mut ProcedureContext, + input: PuzzleWorkRemixInput, +) -> PuzzleAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| remix_puzzle_work_tx(tx, input.clone())) { + Ok(session) => PuzzleAgentSessionProcedureResult { + ok: true, + session_json: Some(serialize_json(&session)), + error_message: None, + }, + Err(message) => PuzzleAgentSessionProcedureResult { + ok: false, + session_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn start_puzzle_run( ctx: &mut ProcedureContext, @@ -509,6 +592,63 @@ pub fn advance_puzzle_next_level( } } +#[spacetimedb::procedure] +pub fn update_puzzle_run_pause( + ctx: &mut ProcedureContext, + input: PuzzleRunPauseInput, +) -> PuzzleRunProcedureResult { + match ctx.try_with_tx(|tx| update_puzzle_run_pause_tx(tx, input.clone())) { + Ok(run) => PuzzleRunProcedureResult { + ok: true, + run_json: Some(serialize_json(&run)), + error_message: None, + }, + Err(message) => PuzzleRunProcedureResult { + ok: false, + run_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn use_puzzle_runtime_prop( + ctx: &mut ProcedureContext, + input: PuzzleRunPropInput, +) -> PuzzleRunProcedureResult { + match ctx.try_with_tx(|tx| use_puzzle_runtime_prop_tx(tx, input.clone())) { + Ok(run) => PuzzleRunProcedureResult { + ok: true, + run_json: Some(serialize_json(&run)), + error_message: None, + }, + Err(message) => PuzzleRunProcedureResult { + ok: false, + run_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn claim_puzzle_work_point_incentive( + ctx: &mut ProcedureContext, + input: PuzzleWorkPointIncentiveClaimInput, +) -> PuzzleWorkProcedureResult { + match ctx.try_with_tx(|tx| claim_puzzle_work_point_incentive_tx(tx, input.clone())) { + Ok(item) => PuzzleWorkProcedureResult { + ok: true, + item_json: Some(serialize_json(&item)), + error_message: None, + }, + Err(message) => PuzzleWorkProcedureResult { + ok: false, + item_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn submit_puzzle_leaderboard_entry( ctx: &mut ProcedureContext, @@ -536,6 +676,7 @@ fn create_puzzle_agent_session_tx( ensure_message_missing(ctx, &input.welcome_message_id)?; let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); let anchor_pack = infer_anchor_pack(&input.seed_text, Some(&input.seed_text)); + let initial_form_draft = build_form_draft_from_seed(&anchor_pack, Some(&input.seed_text)); ctx.db.puzzle_agent_session().insert(PuzzleAgentSessionRow { session_id: input.session_id.clone(), owner_user_id: input.owner_user_id.clone(), @@ -545,7 +686,7 @@ fn create_puzzle_agent_session_tx( progress_percent: 0, stage: PuzzleAgentStage::CollectingAnchors, anchor_pack_json: serialize_json(&anchor_pack), - draft_json: None, + draft_json: Some(serialize_json(&initial_form_draft)), last_assistant_reply: Some(input.welcome_message_text.clone()), published_profile_id: None, created_at, @@ -559,6 +700,13 @@ fn create_puzzle_agent_session_tx( text: input.welcome_message_text, created_at, }); + upsert_puzzle_draft_work_profile( + ctx, + &input.session_id, + &input.owner_user_id, + &initial_form_draft, + input.created_at_micros, + )?; get_puzzle_agent_session_tx( ctx, PuzzleAgentSessionGetInput { @@ -694,9 +842,12 @@ fn compile_puzzle_agent_draft_tx( input: PuzzleDraftCompileInput, ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; - let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?; + if row.seed_text.trim().is_empty() { + return Err("请先填写拼图作品信息".to_string()); + } + let anchor_pack = infer_anchor_pack(&row.seed_text, Some(&row.seed_text)); let messages = list_session_messages(ctx, &row.session_id); - let draft = compile_result_draft(&anchor_pack, &messages); + let draft = compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text)); // 创作中心的拼图草稿卡只是 Agent session 的列表投影, // 每次编译结果页时同步 upsert,保证后续能按 source_session_id 恢复聊天。 upsert_puzzle_draft_work_profile( @@ -736,32 +887,94 @@ fn compile_puzzle_agent_draft_tx( ) } +fn save_puzzle_form_draft_tx( + ctx: &TxContext, + input: PuzzleFormDraftSaveInput, +) -> Result { + let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; + if row.stage != PuzzleAgentStage::CollectingAnchors { + return get_puzzle_agent_session_tx( + ctx, + PuzzleAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ); + } + + let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); + let anchor_pack = infer_anchor_pack(&input.seed_text, Some(&input.seed_text)); + let draft = build_form_draft_from_seed(&anchor_pack, Some(&input.seed_text)); + upsert_puzzle_draft_work_profile( + ctx, + &input.session_id, + &input.owner_user_id, + &draft, + input.saved_at_micros, + )?; + replace_puzzle_agent_session( + ctx, + &row, + PuzzleAgentSessionRow { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + seed_text: input.seed_text, + current_turn: row.current_turn, + progress_percent: 0, + stage: PuzzleAgentStage::CollectingAnchors, + anchor_pack_json: serialize_json(&anchor_pack), + draft_json: Some(serialize_json(&draft)), + last_assistant_reply: row.last_assistant_reply.clone(), + published_profile_id: row.published_profile_id.clone(), + created_at: row.created_at, + updated_at: saved_at, + }, + ); + + get_puzzle_agent_session_tx( + ctx, + PuzzleAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + fn save_puzzle_generated_images_tx( ctx: &TxContext, input: PuzzleGeneratedImagesSaveInput, ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let mut draft = deserialize_draft_required(&row.draft_json)?; + if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? { + // 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。 + draft.levels = levels; + module_puzzle::sync_primary_level_fields(&mut draft); + } let candidates: Vec = json_from_str(&input.candidates_json) .map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?; if candidates.is_empty() { return Err("拼图候选图不能为空".to_string()); } - replace_generated_candidate(&mut draft, candidates); - draft.generation_status = "ready".to_string(); - if let Some(selected) = draft + let target_level = selected_puzzle_level(&draft, input.level_id.as_deref()) + .ok_or_else(|| "拼图关卡不存在".to_string())?; + let mut next_level = target_level; + replace_generated_candidate(&mut next_level.candidates, candidates); + next_level.generation_status = "ready".to_string(); + if let Some(selected) = next_level .candidates .iter() .find(|entry| entry.selected) .cloned() { - draft.selected_candidate_id = Some(selected.candidate_id); - draft.cover_image_src = Some(selected.image_src); - draft.cover_asset_id = Some(selected.asset_id); + next_level.selected_candidate_id = Some(selected.candidate_id); + next_level.cover_image_src = Some(selected.image_src); + next_level.cover_asset_id = Some(selected.asset_id); } + draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?; let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); - let next_stage = if build_result_preview(&draft, Some("创作者")).publish_ready { + let next_stage = if build_result_preview(&draft, Some("百梦主")).publish_ready { PuzzleAgentStage::ReadyToPublish } else { PuzzleAgentStage::ImageRefining @@ -807,10 +1020,40 @@ fn select_puzzle_cover_image_tx( ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let draft = deserialize_draft_required(&row.draft_json)?; - let draft = - apply_selected_candidate(draft, &input.candidate_id).map_err(|error| error.to_string())?; + let target_level = selected_puzzle_level(&draft, input.level_id.as_deref()) + .ok_or_else(|| "拼图关卡不存在".to_string())?; + let level_draft = PuzzleResultDraft { + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + level_name: target_level.level_name.clone(), + summary: draft.summary.clone(), + theme_tags: draft.theme_tags.clone(), + forbidden_directives: draft.forbidden_directives.clone(), + creator_intent: draft.creator_intent.clone(), + anchor_pack: draft.anchor_pack.clone(), + candidates: target_level.candidates.clone(), + selected_candidate_id: target_level.selected_candidate_id.clone(), + cover_image_src: target_level.cover_image_src.clone(), + cover_asset_id: target_level.cover_asset_id.clone(), + generation_status: target_level.generation_status.clone(), + levels: vec![target_level.clone()], + form_draft: None, + }; + let selected_level_draft = apply_selected_candidate(level_draft, &input.candidate_id) + .map_err(|error| error.to_string())?; + let next_level = module_puzzle::PuzzleDraftLevel { + level_id: target_level.level_id, + level_name: target_level.level_name, + picture_description: target_level.picture_description, + candidates: selected_level_draft.candidates, + selected_candidate_id: selected_level_draft.selected_candidate_id, + cover_image_src: selected_level_draft.cover_image_src, + cover_asset_id: selected_level_draft.cover_asset_id, + generation_status: selected_level_draft.generation_status, + }; + let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?; let selected_at = Timestamp::from_micros_since_unix_epoch(input.selected_at_micros); - let next_stage = if build_result_preview(&draft, Some("创作者")).publish_ready { + let next_stage = if build_result_preview(&draft, Some("百梦主")).publish_ready { PuzzleAgentStage::ReadyToPublish } else { PuzzleAgentStage::ImageRefining @@ -858,9 +1101,12 @@ fn publish_puzzle_work_tx( let draft = deserialize_draft_required(&row.draft_json)?; let draft = apply_publish_overrides_to_draft( &draft, + input.work_title.clone(), + input.work_description.clone(), input.level_name.clone(), input.summary.clone(), input.theme_tags.clone(), + deserialize_optional_levels_input(input.levels_json.as_deref())?, ) .map_err(|error| error.to_string())?; let (work_id, profile_id) = build_puzzle_work_ids_from_session_id(&input.session_id); @@ -946,21 +1192,60 @@ fn update_puzzle_work_tx( if theme_tags.is_empty() || theme_tags.len() > PUZZLE_MAX_TAG_COUNT { return Err("拼图标签数量不合法".to_string()); } + let levels = deserialize_optional_levels_input(input.levels_json.as_deref())? + .map(|levels| { + normalize_puzzle_levels(levels, &theme_tags).map_err(|error| error.to_string()) + }) + .transpose()? + .unwrap_or_else(|| build_profile_levels_from_row(&row).unwrap_or_default()); + let preview_draft = PuzzleResultDraft { + work_title: input.work_title.clone(), + work_description: input.work_description.clone(), + level_name: input.level_name.clone(), + summary: input.summary.clone(), + theme_tags: theme_tags.clone(), + forbidden_directives: Vec::new(), + creator_intent: None, + anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?, + candidates: levels + .first() + .map(|level| level.candidates.clone()) + .unwrap_or_default(), + selected_candidate_id: levels + .first() + .and_then(|level| level.selected_candidate_id.clone()), + cover_image_src: input.cover_image_src.clone(), + cover_asset_id: input.cover_asset_id.clone(), + generation_status: levels + .first() + .map(|level| level.generation_status.clone()) + .unwrap_or_else(|| "idle".to_string()), + levels: levels.clone(), + form_draft: None, + }; let next_row = PuzzleWorkProfileRow { profile_id: row.profile_id.clone(), work_id: row.work_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), + work_title: input.work_title, + work_description: input.work_description, level_name: input.level_name, summary: input.summary, theme_tags_json: serialize_json(&theme_tags), cover_image_src: input.cover_image_src, cover_asset_id: input.cover_asset_id, + levels_json: serialize_json(&levels), publication_status: row.publication_status, play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, + point_incentive_total_half_points: row.point_incentive_total_half_points, + point_incentive_claimed_points: row.point_incentive_claimed_points, anchor_pack_json: row.anchor_pack_json.clone(), - publish_ready: row.publish_ready, + publish_ready: build_result_preview(&preview_draft, Some(&row.author_display_name)) + .publish_ready, created_at: row.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(input.updated_at_micros), published_at: row.published_at, @@ -1034,12 +1319,13 @@ fn delete_puzzle_work_tx( } fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result, String> { + let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); let mut items = ctx .db .puzzle_work_profile() .iter() .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) - .map(|row| build_puzzle_work_profile_from_row(&row)) + .map(|row| build_puzzle_work_profile_from_row_with_recent_count(ctx, &row, now_micros)) .collect::, _>>()?; items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); Ok(items) @@ -1058,7 +1344,231 @@ fn get_puzzle_gallery_detail_tx( if row.publication_status != PuzzlePublicationStatus::Published { return Err("拼图作品尚未发布".to_string()); } - build_puzzle_work_profile_from_row(&row) + build_puzzle_work_profile_from_row_with_recent_count( + ctx, + &row, + ctx.timestamp.to_micros_since_unix_epoch(), + ) +} + +fn record_puzzle_work_like_tx( + ctx: &TxContext, + input: PuzzleWorkLikeInput, +) -> Result { + let profile_id = input.profile_id.trim(); + let user_id = input.user_id.trim(); + if profile_id.is_empty() || user_id.is_empty() { + return Err("拼图 like 参数不能为空".to_string()); + } + let row = ctx + .db + .puzzle_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) + .ok_or_else(|| "拼图已发布作品不存在,无法点赞".to_string())?; + let inserted_like = record_public_work_like( + ctx, + PublicWorkLikeRecordInput { + source_type: "puzzle".to_string(), + owner_user_id: row.owner_user_id.clone(), + profile_id: row.profile_id.clone(), + user_id: user_id.to_string(), + liked_at_micros: input.liked_at_micros, + }, + )?; + + let current_row = if inserted_like { + let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros); + let next_row = PuzzleWorkProfileRow { + profile_id: row.profile_id.clone(), + work_id: row.work_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + work_title: row.work_title.clone(), + work_description: row.work_description.clone(), + level_name: row.level_name.clone(), + summary: row.summary.clone(), + theme_tags_json: row.theme_tags_json.clone(), + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + levels_json: row.levels_json.clone(), + publication_status: row.publication_status, + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count.saturating_add(1), + point_incentive_total_half_points: row.point_incentive_total_half_points, + point_incentive_claimed_points: row.point_incentive_claimed_points, + anchor_pack_json: row.anchor_pack_json.clone(), + publish_ready: row.publish_ready, + created_at: row.created_at, + updated_at: liked_at, + published_at: row.published_at, + }; + replace_puzzle_work_profile(ctx, &row, next_row); + ctx.db + .puzzle_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .ok_or_else(|| "拼图点赞更新失败".to_string())? + } else { + row + }; + + build_puzzle_work_profile_from_row_with_recent_count( + ctx, + ¤t_row, + ctx.timestamp.to_micros_since_unix_epoch(), + ) +} + +fn remix_puzzle_work_tx( + ctx: &TxContext, + input: PuzzleWorkRemixInput, +) -> Result { + let source_profile_id = input.source_profile_id.trim(); + let target_owner_user_id = input.target_owner_user_id.trim(); + let target_session_id = input.target_session_id.trim(); + let target_profile_id = input.target_profile_id.trim(); + let target_work_id = input.target_work_id.trim(); + if source_profile_id.is_empty() + || target_owner_user_id.is_empty() + || target_session_id.is_empty() + || target_profile_id.is_empty() + || target_work_id.is_empty() + { + return Err("拼图 remix 参数不能为空".to_string()); + } + if input.author_display_name.trim().is_empty() { + return Err("拼图 remix 作者名不能为空".to_string()); + } + ensure_session_missing(ctx, target_session_id)?; + ensure_message_missing(ctx, input.welcome_message_id.trim())?; + if ctx + .db + .puzzle_work_profile() + .profile_id() + .find(&target_profile_id.to_string()) + .is_some() + { + return Err("拼图 remix 目标作品已存在".to_string()); + } + + let source = ctx + .db + .puzzle_work_profile() + .profile_id() + .find(&source_profile_id.to_string()) + .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) + .ok_or_else(|| "拼图已发布源作品不存在".to_string())?; + let source_profile = build_puzzle_work_profile_from_row(&source)?; + let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros); + + replace_puzzle_work_profile( + ctx, + &source, + PuzzleWorkProfileRow { + profile_id: source.profile_id.clone(), + work_id: source.work_id.clone(), + owner_user_id: source.owner_user_id.clone(), + source_session_id: source.source_session_id.clone(), + author_display_name: source.author_display_name.clone(), + work_title: source.work_title.clone(), + work_description: source.work_description.clone(), + level_name: source.level_name.clone(), + summary: source.summary.clone(), + theme_tags_json: source.theme_tags_json.clone(), + cover_image_src: source.cover_image_src.clone(), + cover_asset_id: source.cover_asset_id.clone(), + levels_json: source.levels_json.clone(), + publication_status: source.publication_status, + play_count: source.play_count, + remix_count: source.remix_count.saturating_add(1), + like_count: source.like_count, + point_incentive_total_half_points: source.point_incentive_total_half_points, + point_incentive_claimed_points: source.point_incentive_claimed_points, + anchor_pack_json: source.anchor_pack_json.clone(), + publish_ready: source.publish_ready, + created_at: source.created_at, + updated_at: remixed_at, + published_at: source.published_at, + }, + ); + + let draft = PuzzleResultDraft { + work_title: source_profile.work_title.clone(), + work_description: source_profile.work_description.clone(), + level_name: source_profile.level_name.clone(), + summary: source_profile.summary.clone(), + theme_tags: source_profile.theme_tags.clone(), + forbidden_directives: Vec::new(), + creator_intent: None, + anchor_pack: source_profile.anchor_pack.clone(), + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: source_profile.cover_image_src.clone(), + cover_asset_id: source_profile.cover_asset_id.clone(), + generation_status: "ready".to_string(), + levels: source_profile.levels.clone(), + form_draft: None, + }; + ctx.db.puzzle_agent_session().insert(PuzzleAgentSessionRow { + session_id: target_session_id.to_string(), + owner_user_id: target_owner_user_id.to_string(), + seed_text: source_profile.summary.clone(), + current_turn: 1, + progress_percent: 88, + stage: PuzzleAgentStage::DraftReady, + anchor_pack_json: serialize_json(&source_profile.anchor_pack), + draft_json: Some(serialize_json(&draft)), + last_assistant_reply: Some("已从公开作品 Remix 出新的拼图草稿。".to_string()), + published_profile_id: None, + created_at: remixed_at, + updated_at: remixed_at, + }); + ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow { + message_id: input.welcome_message_id, + session_id: target_session_id.to_string(), + role: PuzzleAgentMessageRole::Assistant, + kind: PuzzleAgentMessageKind::Summary, + text: "已复制公开作品为你的草稿。".to_string(), + created_at: remixed_at, + }); + ctx.db.puzzle_work_profile().insert(PuzzleWorkProfileRow { + profile_id: target_profile_id.to_string(), + work_id: target_work_id.to_string(), + owner_user_id: target_owner_user_id.to_string(), + source_session_id: Some(target_session_id.to_string()), + author_display_name: input.author_display_name.trim().to_string(), + work_title: source_profile.work_title, + work_description: source_profile.work_description, + level_name: source_profile.level_name, + summary: source_profile.summary, + theme_tags_json: serialize_json(&source_profile.theme_tags), + cover_image_src: source_profile.cover_image_src, + cover_asset_id: source_profile.cover_asset_id, + levels_json: serialize_json(&source_profile.levels), + publication_status: PuzzlePublicationStatus::Draft, + play_count: 0, + remix_count: 0, + like_count: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, + anchor_pack_json: serialize_json(&source_profile.anchor_pack), + publish_ready: true, + created_at: remixed_at, + updated_at: remixed_at, + published_at: None, + }); + + get_puzzle_agent_session_tx( + ctx, + PuzzleAgentSessionGetInput { + session_id: target_session_id.to_string(), + owner_user_id: target_owner_user_id.to_string(), + }, + ) } fn start_puzzle_run_tx( @@ -1089,15 +1599,27 @@ fn start_puzzle_run_tx( { return Err("入口拼图作品未发布".to_string()); } - let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?; + let mut entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?; if entry_profile.cover_image_src.is_none() { return Err("入口拼图作品缺少正式图片".to_string()); } if entry_profile.theme_tags.is_empty() { return Err("入口拼图作品缺少标签".to_string()); } - let mut run = - start_run(input.run_id.clone(), &entry_profile, 0).map_err(|error| error.to_string())?; + let mut cleared_level_count = 0; + if let Some(level) = selected_profile_level(&entry_profile, input.level_id.as_deref())? { + cleared_level_count = + module_puzzle::resolve_restart_cleared_level_count(&entry_profile, &level.level_id); + entry_profile = profile_for_single_level(&entry_profile, &level); + } + let started_at_ms = micros_to_millis(input.started_at_micros); + let mut run = module_puzzle::start_run_at( + input.run_id.clone(), + &entry_profile, + cleared_level_count, + started_at_ms, + ) + .map_err(|error| error.to_string())?; let current_grid_size = run.current_grid_size; let current_profile_id = entry_profile.profile_id.clone(); hydrate_puzzle_leaderboard_entries( @@ -1107,14 +1629,18 @@ fn start_puzzle_run_tx( current_profile_id.as_str(), current_grid_size, ); - run.recommended_next_profile_id = select_next_profile( - &entry_profile, - &run.played_profile_ids, - &list_published_puzzle_profiles(ctx)?, - ) - .map(|value| value.profile_id.clone()); + refresh_next_level_handoff(ctx, &mut run)?; if entry_profile_row.publication_status == PuzzlePublicationStatus::Published { + record_public_work_play( + ctx, + PublicWorkPlayRecordInput { + source_type: "puzzle".to_string(), + owner_user_id: entry_profile_row.owner_user_id.clone(), + profile_id: entry_profile_row.profile_id.clone(), + played_at_micros: input.started_at_micros, + }, + )?; increment_puzzle_profile_play_count(ctx, &entry_profile_row, input.started_at_micros); upsert_puzzle_profile_played_work( ctx, @@ -1132,7 +1658,15 @@ fn get_puzzle_run_tx( input: PuzzleRunGetInput, ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; - let mut run = deserialize_run(&row.snapshot_json)?; + let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); + let mut run = module_puzzle::resolve_puzzle_run_timer_at( + deserialize_run(&row.snapshot_json)?, + micros_to_millis(now_micros), + ); + refresh_next_level_handoff(ctx, &mut run)?; + if serialize_json(&run) != row.snapshot_json { + replace_puzzle_runtime_run(ctx, &row, &run, now_micros); + } if let Some((profile_id, grid_size)) = run .current_level .as_ref() @@ -1155,9 +1689,27 @@ fn swap_puzzle_pieces_tx( ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; let current_run = deserialize_run(&row.snapshot_json)?; - let mut next_run = swap_pieces(¤t_run, &input.first_piece_id, &input.second_piece_id) - .map_err(|error| error.to_string())?; - refresh_next_profile_recommendation(ctx, &mut next_run)?; + let mut next_run = module_puzzle::swap_pieces_at( + ¤t_run, + &input.first_piece_id, + &input.second_piece_id, + micros_to_millis(input.swapped_at_micros), + ) + .map_err(|error| error.to_string())?; + refresh_next_level_handoff(ctx, &mut next_run)?; + if let Some((profile_id, grid_size)) = next_run + .current_level + .as_ref() + .map(|level| (level.profile_id.clone(), level.grid_size)) + { + hydrate_puzzle_leaderboard_entries( + ctx, + &mut next_run, + &input.owner_user_id, + &profile_id, + grid_size, + ); + } replace_puzzle_runtime_run(ctx, &row, &next_run, input.swapped_at_micros); Ok(next_run) } @@ -1168,14 +1720,28 @@ fn drag_puzzle_piece_or_group_tx( ) -> Result { let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; let current_run = deserialize_run(&row.snapshot_json)?; - let mut next_run = module_puzzle::drag_piece_or_group( + let mut next_run = module_puzzle::drag_piece_or_group_at( ¤t_run, &input.piece_id, input.target_row, input.target_col, + micros_to_millis(input.dragged_at_micros), ) .map_err(|error| error.to_string())?; - refresh_next_profile_recommendation(ctx, &mut next_run)?; + refresh_next_level_handoff(ctx, &mut next_run)?; + if let Some((profile_id, grid_size)) = next_run + .current_level + .as_ref() + .map(|level| (level.profile_id.clone(), level.grid_size)) + { + hydrate_puzzle_leaderboard_entries( + ctx, + &mut next_run, + &input.owner_user_id, + &profile_id, + grid_size, + ); + } replace_puzzle_runtime_run(ctx, &row, &next_run, input.dragged_at_micros); Ok(next_run) } @@ -1193,23 +1759,48 @@ fn advance_puzzle_next_level_tx( if current_level.status != PuzzleRuntimeLevelStatus::Cleared { return Err("当前关卡尚未通关".to_string()); } - let current_profile = build_puzzle_work_profile_from_row( - &ctx.db - .puzzle_work_profile() - .profile_id() - .find(¤t_level.profile_id) - .ok_or_else(|| "当前拼图作品不存在".to_string())?, - )?; - let candidates = list_published_puzzle_profiles(ctx)?; - let next_profile = select_next_profile( - ¤t_profile, - ¤t_run.played_profile_ids, - &candidates, - ) - .ok_or_else(|| "没有可用的下一关候选".to_string())? - .clone(); - let mut next_run = module_puzzle::advance_next_level(¤t_run, &next_profile) - .map_err(|error| error.to_string())?; + let current_profile_row = ctx + .db + .puzzle_work_profile() + .profile_id() + .find(¤t_level.profile_id) + .ok_or_else(|| "当前拼图作品不存在".to_string())?; + let current_profile = build_puzzle_work_profile_from_row(¤t_profile_row)?; + let same_work_next_profile = + selected_profile_level_after_runtime_level(¤t_profile, current_level) + .map(|level| profile_for_single_level(¤t_profile, &level)); + let similar_work_next_profile = if same_work_next_profile.is_none() { + let candidates = list_published_puzzle_profiles(ctx)?; + select_next_profiles( + ¤t_profile, + ¤t_run.played_profile_ids, + &candidates, + 1, + ) + .into_iter() + .next() + .cloned() + } else { + None + }; + let next_profile = same_work_next_profile + .as_ref() + .or(similar_work_next_profile.as_ref()) + .ok_or_else(|| "没有可用的下一关候选".to_string())?; + let mut next_run = if same_work_next_profile.is_some() { + module_puzzle::advance_next_level_at( + ¤t_run, + next_profile, + micros_to_millis(input.advanced_at_micros), + ) + } else { + module_puzzle::advance_to_new_work_first_level_at( + ¤t_run, + next_profile, + micros_to_millis(input.advanced_at_micros), + ) + } + .map_err(|error| error.to_string())?; let next_grid_size = next_run.current_grid_size; let next_profile_id = next_profile.profile_id.clone(); hydrate_puzzle_leaderboard_entries( @@ -1219,9 +1810,7 @@ fn advance_puzzle_next_level_tx( &next_profile_id, next_grid_size, ); - next_run.recommended_next_profile_id = - select_next_profile(&next_profile, &next_run.played_profile_ids, &candidates) - .map(|value| value.profile_id.clone()); + refresh_next_level_handoff(ctx, &mut next_run)?; if let Some(next_profile_row) = ctx .db @@ -1229,6 +1818,15 @@ fn advance_puzzle_next_level_tx( .profile_id() .find(&next_profile.profile_id) { + record_public_work_play( + ctx, + PublicWorkPlayRecordInput { + source_type: "puzzle".to_string(), + owner_user_id: next_profile_row.owner_user_id.clone(), + profile_id: next_profile_row.profile_id.clone(), + played_at_micros: input.advanced_at_micros, + }, + )?; increment_puzzle_profile_play_count(ctx, &next_profile_row, input.advanced_at_micros); upsert_puzzle_profile_played_work( ctx, @@ -1241,6 +1839,180 @@ fn advance_puzzle_next_level_tx( Ok(next_run) } +fn update_puzzle_run_pause_tx( + ctx: &TxContext, + input: PuzzleRunPauseInput, +) -> Result { + let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; + let current_run = deserialize_run(&row.snapshot_json)?; + let next_run = module_puzzle::set_puzzle_run_paused_at( + ¤t_run, + input.paused, + micros_to_millis(input.updated_at_micros), + ) + .map_err(|error| error.to_string())?; + let mut hydrated_run = next_run; + refresh_next_level_handoff(ctx, &mut hydrated_run)?; + replace_puzzle_runtime_run(ctx, &row, &hydrated_run, input.updated_at_micros); + if let Some((profile_id, grid_size)) = hydrated_run + .current_level + .as_ref() + .map(|level| (level.profile_id.clone(), level.grid_size)) + { + hydrate_puzzle_leaderboard_entries( + ctx, + &mut hydrated_run, + &input.owner_user_id, + &profile_id, + grid_size, + ); + } + Ok(hydrated_run) +} + +fn use_puzzle_runtime_prop_tx( + ctx: &TxContext, + input: PuzzleRunPropInput, +) -> Result { + let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; + let current_run = deserialize_run(&row.snapshot_json)?; + let next_run = match input.prop_kind.as_str() { + "freezeTime" | "freeze_time" => module_puzzle::apply_puzzle_freeze_time_at( + ¤t_run, + micros_to_millis(input.used_at_micros), + ) + .map_err(|error| error.to_string())?, + "extendTime" | "extend_time" => module_puzzle::extend_failed_puzzle_time_at( + ¤t_run, + micros_to_millis(input.used_at_micros), + ) + .map_err(|error| error.to_string())?, + "hint" => module_puzzle::set_puzzle_run_paused_at( + ¤t_run, + false, + micros_to_millis(input.used_at_micros), + ) + .map_err(|error| error.to_string())?, + "reference" => module_puzzle::set_puzzle_run_paused_at( + ¤t_run, + true, + micros_to_millis(input.used_at_micros), + ) + .map_err(|error| error.to_string())?, + _ => return Err("未知拼图道具".to_string()), + }; + let mut hydrated_run = next_run; + refresh_next_level_handoff(ctx, &mut hydrated_run)?; + if let Some(profile_id) = hydrated_run + .current_level + .as_ref() + .map(|level| level.profile_id.clone()) + { + accrue_puzzle_point_incentive( + ctx, + &profile_id, + &input.owner_user_id, + input.spent_points, + input.used_at_micros, + )?; + } + replace_puzzle_runtime_run(ctx, &row, &hydrated_run, input.used_at_micros); + if let Some((profile_id, grid_size)) = hydrated_run + .current_level + .as_ref() + .map(|level| (level.profile_id.clone(), level.grid_size)) + { + hydrate_puzzle_leaderboard_entries( + ctx, + &mut hydrated_run, + &input.owner_user_id, + &profile_id, + grid_size, + ); + } + Ok(hydrated_run) +} + +fn claim_puzzle_work_point_incentive_tx( + ctx: &TxContext, + input: PuzzleWorkPointIncentiveClaimInput, +) -> Result { + let profile_id = input.profile_id.trim(); + let owner_user_id = input.owner_user_id.trim(); + if profile_id.is_empty() || owner_user_id.is_empty() { + return Err("拼图积分激励参数不能为空".to_string()); + } + + let row = ctx + .db + .puzzle_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .ok_or_else(|| "拼图作品不存在".to_string())?; + if row.owner_user_id != owner_user_id { + return Err("无权领取该作品的积分激励".to_string()); + } + + let claimable_points = module_puzzle::puzzle_point_incentive_claimable_points( + row.point_incentive_total_half_points, + row.point_incentive_claimed_points, + ); + if claimable_points == 0 { + return Err("暂无可领取积分激励".to_string()); + } + + let claimed_at = Timestamp::from_micros_since_unix_epoch(input.claimed_at_micros); + let next_row = PuzzleWorkProfileRow { + profile_id: row.profile_id.clone(), + work_id: row.work_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + work_title: row.work_title.clone(), + work_description: row.work_description.clone(), + level_name: row.level_name.clone(), + summary: row.summary.clone(), + theme_tags_json: row.theme_tags_json.clone(), + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + levels_json: row.levels_json.clone(), + publication_status: row.publication_status, + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, + point_incentive_total_half_points: row.point_incentive_total_half_points, + point_incentive_claimed_points: row + .point_incentive_claimed_points + .saturating_add(claimable_points), + anchor_pack_json: row.anchor_pack_json.clone(), + publish_ready: row.publish_ready, + created_at: row.created_at, + updated_at: claimed_at, + published_at: row.published_at, + }; + replace_puzzle_work_profile(ctx, &row, next_row); + + grant_profile_wallet_points( + ctx, + owner_user_id, + claimable_points, + RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim, + &format!( + "puzzle_author_incentive_claim:{}:{}:{}", + profile_id, owner_user_id, input.claimed_at_micros + ), + claimed_at, + )?; + + let updated = ctx + .db + .puzzle_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .ok_or_else(|| "拼图积分激励领取更新失败".to_string())?; + build_puzzle_work_profile_from_row(&updated) +} + fn submit_puzzle_leaderboard_entry_tx( ctx: &TxContext, input: PuzzleLeaderboardSubmitInput, @@ -1251,13 +2023,15 @@ fn submit_puzzle_leaderboard_entry_tx( .current_level .as_ref() .ok_or_else(|| "拼图关卡不存在".to_string())?; - if current_level.status != PuzzleRuntimeLevelStatus::Cleared { - return Err("当前关卡尚未通关".to_string()); + if input.profile_id.trim().is_empty() { + return Err("提交成绩的拼图作品不能为空".to_string()); } - if current_level.profile_id != input.profile_id { - return Err("提交成绩的拼图作品与当前关卡不匹配".to_string()); + if !module_puzzle::is_supported_puzzle_grid_size(input.grid_size) { + return Err("提交成绩的网格规格无效".to_string()); } - if current_level.grid_size != input.grid_size { + let matches_service_level = + current_level.profile_id == input.profile_id && current_level.grid_size == input.grid_size; + if current_level.profile_id == input.profile_id && current_level.grid_size != input.grid_size { return Err("提交成绩的网格规格与当前关卡不匹配".to_string()); } let current_profile_row = ctx @@ -1266,6 +2040,9 @@ fn submit_puzzle_leaderboard_entry_tx( .profile_id() .find(&input.profile_id) .ok_or_else(|| "提交成绩的拼图作品不存在".to_string())?; + if !matches_service_level && !is_frontend_puzzle_level_candidate(&run, &input.profile_id) { + return Err("提交成绩的拼图作品与当前关卡不匹配".to_string()); + } if current_profile_row.publication_status != PuzzlePublicationStatus::Published { hydrate_puzzle_leaderboard_entries( ctx, @@ -1308,15 +2085,51 @@ fn submit_puzzle_leaderboard_entry_tx( &input.owner_user_id, 10, ); - if let Some(level) = run.current_level.as_mut() { - level.elapsed_ms = Some(input.elapsed_ms.max(1_000)); - level.leaderboard_entries = leaderboard_entries.clone(); + if matches_service_level { + if let Some(level) = run.current_level.as_mut() { + // 拼图拖动、合并与通关判定由前端运行态即时裁决;服务端只负责真实榜单。 + // 因此提交榜单时不能要求 SpacetimeDB 里的旧棋盘快照也已经通关。 + level.status = PuzzleRuntimeLevelStatus::Cleared; + level.cleared_at_ms = Some(micros_to_millis(input.submitted_at_micros)); + level.elapsed_ms = Some(input.elapsed_ms.max(1_000)); + level.leaderboard_entries = leaderboard_entries.clone(); + } + run.cleared_level_count = run.cleared_level_count.max(run.current_level_index); + } else { + // 拼图拖动、合并与通关判定由前端运行态即时裁决;服务端只负责真实榜单。 + // 前端通过 local-next-level 推进到第二关后,服务端旧 run 可能仍停在上一关。 + // 此时只返回真实榜单,前端会把榜单合并回当前本地关卡,不能用旧棋盘覆盖前端状态。 + log::info!( + "puzzle leaderboard submitted for frontend-only level: run_id={}, service_profile_id={}, submitted_profile_id={}", + input.run_id, + current_level.profile_id, + input.profile_id + ); } run.leaderboard_entries = leaderboard_entries; + refresh_next_level_handoff(ctx, &mut run)?; replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros); Ok(run) } +fn is_frontend_puzzle_level_candidate(run: &PuzzleRunSnapshot, profile_id: &str) -> bool { + run.recommended_next_profile_id + .as_ref() + .is_some_and(|candidate_profile_id| candidate_profile_id == profile_id) + || run + .next_level_profile_id + .as_ref() + .is_some_and(|candidate_profile_id| candidate_profile_id == profile_id) + || run + .recommended_next_works + .iter() + .any(|candidate| candidate.profile_id == profile_id) + || run + .played_profile_ids + .iter() + .any(|played_profile_id| played_profile_id == profile_id) +} + fn build_puzzle_agent_session_snapshot( ctx: &TxContext, row: &PuzzleAgentSessionRow, @@ -1326,7 +2139,7 @@ fn build_puzzle_agent_session_snapshot( let messages = list_session_messages(ctx, &row.session_id); let result_preview = draft .as_ref() - .map(|value| build_result_preview(value, Some("创作者"))); + .map(|value| build_result_preview(value, Some("百梦主"))); Ok(PuzzleAgentSessionSnapshot { session_id: row.session_id.clone(), @@ -1349,6 +2162,23 @@ fn build_puzzle_agent_session_snapshot( fn build_puzzle_work_profile_from_row( row: &PuzzleWorkProfileRow, +) -> Result { + build_puzzle_work_profile_from_row_without_recent_count(row) +} + +fn build_puzzle_work_profile_from_row_with_recent_count( + ctx: &TxContext, + row: &PuzzleWorkProfileRow, + now_micros: i64, +) -> Result { + let mut profile = build_puzzle_work_profile_from_row_without_recent_count(row)?; + profile.recent_play_count_7d = + count_recent_public_work_plays(ctx, "puzzle", &row.profile_id, now_micros); + Ok(profile) +} + +fn build_puzzle_work_profile_from_row_without_recent_count( + row: &PuzzleWorkProfileRow, ) -> Result { Ok(PuzzleWorkProfile { work_id: row.work_id.clone(), @@ -1356,22 +2186,96 @@ fn build_puzzle_work_profile_from_row( owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), + work_title: if row.work_title.trim().is_empty() { + row.level_name.clone() + } else { + row.work_title.clone() + }, + work_description: if row.work_description.trim().is_empty() { + row.summary.clone() + } else { + row.work_description.clone() + }, level_name: row.level_name.clone(), summary: row.summary.clone(), theme_tags: deserialize_theme_tags(&row.theme_tags_json)?, cover_image_src: row.cover_image_src.clone(), cover_asset_id: row.cover_asset_id.clone(), + levels: build_profile_levels_from_row(row)?, publication_status: row.publication_status, updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, + point_incentive_total_half_points: row.point_incentive_total_half_points, + point_incentive_claimed_points: row.point_incentive_claimed_points, + recent_play_count_7d: 0, publish_ready: row.publish_ready, anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?, }) } +fn build_profile_levels_from_row( + row: &PuzzleWorkProfileRow, +) -> Result, String> { + let levels = deserialize_levels_json(&row.levels_json)?; + if !levels.is_empty() { + return Ok(levels); + } + Ok(vec![module_puzzle::PuzzleDraftLevel { + level_id: "puzzle-level-1".to_string(), + level_name: row.level_name.clone(), + picture_description: row.summary.clone(), + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + generation_status: if row.cover_image_src.is_some() { + "ready".to_string() + } else { + "idle".to_string() + }, + }]) +} + +fn selected_profile_level( + profile: &PuzzleWorkProfile, + level_id: Option<&str>, +) -> Result, String> { + let Some(level_id) = level_id.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) else { + return Ok(None); + }; + profile + .levels + .iter() + .find(|level| level.level_id == level_id) + .cloned() + .map(Some) + .ok_or_else(|| "入口拼图关卡不存在".to_string()) +} + +fn profile_for_single_level( + profile: &PuzzleWorkProfile, + level: &module_puzzle::PuzzleDraftLevel, +) -> PuzzleWorkProfile { + let mut next_profile = profile.clone(); + next_profile.level_name = level.level_name.clone(); + next_profile.cover_image_src = level.cover_image_src.clone(); + next_profile.cover_asset_id = level.cover_asset_id.clone(); + next_profile.levels = vec![level.clone()]; + next_profile +} + fn build_puzzle_work_ids_from_session_id(session_id: &str) -> (String, String) { let stable_suffix = session_id .strip_prefix("puzzle-session-") @@ -1382,6 +2286,13 @@ fn build_puzzle_work_ids_from_session_id(session_id: &str) -> (String, String) { ) } +fn micros_to_millis(value: i64) -> u64 { + if value <= 0 { + return 0; + } + (value as u64).saturating_div(1_000) +} + fn upsert_puzzle_draft_work_profile( ctx: &TxContext, session_id: &str, @@ -1394,13 +2305,29 @@ fn upsert_puzzle_draft_work_profile( if existing.publication_status == PuzzlePublicationStatus::Published { return Ok(()); } + let mut profile = create_work_profile( + work_id, + profile_id, + owner_user_id.to_string(), + Some(session_id.to_string()), + existing.author_display_name.clone(), + draft, + updated_at_micros, + ) + .map_err(|error| error.to_string())?; + profile.play_count = existing.play_count; + profile.remix_count = existing.remix_count; + profile.like_count = existing.like_count; + profile.point_incentive_total_half_points = existing.point_incentive_total_half_points; + profile.point_incentive_claimed_points = existing.point_incentive_claimed_points; + return upsert_puzzle_work_profile(ctx, profile); } let profile = create_work_profile( work_id, profile_id, owner_user_id.to_string(), Some(session_id.to_string()), - "创作者".to_string(), + "百梦主".to_string(), draft, updated_at_micros, ) @@ -1557,15 +2484,26 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re owner_user_id: profile.owner_user_id, source_session_id: profile.source_session_id, author_display_name: profile.author_display_name, + work_title: profile.work_title, + work_description: profile.work_description, level_name: profile.level_name, summary: profile.summary, theme_tags_json: serialize_json(&profile.theme_tags), cover_image_src: profile.cover_image_src, cover_asset_id: profile.cover_asset_id, + levels_json: serialize_json(&profile.levels), publication_status: profile.publication_status, // 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于 // 广场消费数据,不能因为重新发布被清零。 play_count: existing.play_count.max(profile.play_count), + remix_count: existing.remix_count.max(profile.remix_count), + like_count: existing.like_count.max(profile.like_count), + point_incentive_total_half_points: existing + .point_incentive_total_half_points + .max(profile.point_incentive_total_half_points), + point_incentive_claimed_points: existing + .point_incentive_claimed_points + .max(profile.point_incentive_claimed_points), anchor_pack_json: serialize_json(&profile.anchor_pack), publish_ready: profile.publish_ready, created_at: existing.created_at, @@ -1584,13 +2522,20 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re owner_user_id: profile.owner_user_id, source_session_id: profile.source_session_id, author_display_name: profile.author_display_name, + work_title: profile.work_title, + work_description: profile.work_description, level_name: profile.level_name, summary: profile.summary, theme_tags_json: serialize_json(&profile.theme_tags), cover_image_src: profile.cover_image_src, cover_asset_id: profile.cover_asset_id, + levels_json: serialize_json(&profile.levels), publication_status: profile.publication_status, play_count: profile.play_count, + remix_count: profile.remix_count, + like_count: profile.like_count, + point_incentive_total_half_points: profile.point_incentive_total_half_points, + point_incentive_claimed_points: profile.point_incentive_claimed_points, anchor_pack_json: serialize_json(&profile.anchor_pack), publish_ready: profile.publish_ready, created_at: Timestamp::from_micros_since_unix_epoch(profile.updated_at_micros), @@ -1647,6 +2592,7 @@ fn insert_puzzle_runtime_run( created_at: timestamp, updated_at: timestamp, }); + upsert_puzzle_profile_save_archive(ctx, run, owner_user_id, created_at_micros)?; Ok(()) } @@ -1668,13 +2614,262 @@ fn replace_puzzle_runtime_run( .unwrap_or_else(|| current.current_profile_id.clone()), cleared_level_count: run.cleared_level_count, current_level_index: run.current_level_index, - current_grid_size: resolve_puzzle_grid_size(run.cleared_level_count), + current_grid_size: run.current_grid_size, played_profile_ids_json: serialize_json(&run.played_profile_ids), previous_level_tags_json: serialize_json(&run.previous_level_tags), snapshot_json: serialize_json(run), created_at: current.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }); + if let Err(error) = + upsert_puzzle_profile_save_archive(ctx, run, ¤t.owner_user_id, updated_at_micros) + { + log::warn!("拼图存档投影同步失败: {}", error); + } +} + +fn upsert_puzzle_profile_save_archive( + ctx: &TxContext, + run: &PuzzleRunSnapshot, + user_id: &str, + saved_at_micros: i64, +) -> Result<(), String> { + let user_id = user_id.trim(); + if user_id.is_empty() { + return Ok(()); + } + let Some(current_level) = run.current_level.as_ref() else { + return Ok(()); + }; + let world_key = format!("puzzle:{}", run.entry_profile_id); + let target = resolve_puzzle_archive_target(ctx, run, current_level)?; + let work_title = resolve_puzzle_archive_work_title(ctx, &target.profile_id, &target.level_name); + let subtitle = build_puzzle_archive_subtitle(target.level_index, &target.level_name); + + // 中文注释:拼图存档只保存恢复入口所需的最小运行态索引,棋盘真相继续放在 puzzle_runtime_run。 + let game_state_json = json_to_string(&json!({ + "runtimeKind": "puzzle", + "runId": run.run_id, + "entryProfileId": run.entry_profile_id, + "currentProfileId": target.profile_id.clone(), + "currentLevelIndex": target.level_index, + "currentLevelId": target.level_id.clone(), + "status": target.status.as_str(), + })) + .unwrap_or_else(|_| "{}".to_string()); + + upsert_profile_save_archive( + ctx, + ProfileSaveArchiveUpsertInput { + user_id: user_id.to_string(), + world_key, + owner_user_id: target.owner_user_id, + profile_id: Some(run.entry_profile_id.clone()), + world_type: Some("PUZZLE".to_string()), + world_name: work_title, + subtitle, + summary_text: puzzle_archive_summary_text(target.status), + cover_image_src: target.cover_image_src, + bottom_tab: "puzzle".to_string(), + game_state_json, + current_story_json: None, + saved_at_micros, + }, + ) +} + +struct PuzzleArchiveTarget { + profile_id: String, + level_index: u32, + level_id: Option, + level_name: String, + status: PuzzleRuntimeLevelStatus, + cover_image_src: Option, + owner_user_id: Option, +} + +fn resolve_puzzle_archive_target( + ctx: &TxContext, + run: &PuzzleRunSnapshot, + current_level: &module_puzzle::PuzzleRuntimeLevelSnapshot, +) -> Result { + // 中文注释:通关后若已经算出同作品下一关,存档页直接投影到下一关入口; + // 跨作品候选需要玩家选择,不能在存档里提前替玩家切换作品。 + let owner_user_id = resolve_puzzle_current_owner_user_id(ctx, ¤t_level.profile_id); + if current_level.status != PuzzleRuntimeLevelStatus::Cleared { + return Ok(PuzzleArchiveTarget { + profile_id: current_level.profile_id.clone(), + level_index: current_level.level_index, + level_id: current_level.level_id.clone(), + level_name: current_level.level_name.clone(), + status: current_level.status, + cover_image_src: current_level.cover_image_src.clone(), + owner_user_id, + }); + } + + let Some(next_level_id) = run + .next_level_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + return Ok(PuzzleArchiveTarget { + profile_id: current_level.profile_id.clone(), + level_index: current_level.level_index, + level_id: current_level.level_id.clone(), + level_name: current_level.level_name.clone(), + status: current_level.status, + cover_image_src: current_level.cover_image_src.clone(), + owner_user_id, + }); + }; + if run.next_level_profile_id.as_deref() != Some(current_level.profile_id.as_str()) + || run.next_level_mode != PUZZLE_NEXT_LEVEL_MODE_SAME_WORK + { + return Ok(PuzzleArchiveTarget { + profile_id: current_level.profile_id.clone(), + level_index: current_level.level_index, + level_id: current_level.level_id.clone(), + level_name: current_level.level_name.clone(), + status: current_level.status, + cover_image_src: current_level.cover_image_src.clone(), + owner_user_id, + }); + } + + let current_profile = build_puzzle_work_profile_from_row( + &ctx.db + .puzzle_work_profile() + .profile_id() + .find(¤t_level.profile_id) + .ok_or_else(|| "当前拼图作品不存在".to_string())?, + )?; + let next_level = current_profile + .levels + .iter() + .find(|level| level.level_id == next_level_id) + .cloned() + .ok_or_else(|| "下一关拼图关卡不存在".to_string())?; + + Ok(PuzzleArchiveTarget { + profile_id: current_profile.profile_id, + level_index: current_level.level_index.saturating_add(1), + level_id: Some(next_level.level_id), + level_name: next_level.level_name, + status: PuzzleRuntimeLevelStatus::Playing, + cover_image_src: next_level.cover_image_src, + owner_user_id, + }) +} + +fn resolve_puzzle_archive_work_title( + ctx: &TxContext, + profile_id: &str, + fallback_level_name: &str, +) -> String { + // 中文注释:存档主标题必须是作品名;历史数据或异常行缺失作品名时才回退到关卡名。 + ctx.db + .puzzle_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .map(|row| { + let title = row.work_title.trim(); + if title.is_empty() { + fallback_level_name.to_string() + } else { + title.to_string() + } + }) + .unwrap_or_else(|| fallback_level_name.to_string()) +} + +fn build_puzzle_archive_subtitle(level_index: u32, level_name: &str) -> String { + let level_label = format!("第 {level_index} 关"); + let level_name = level_name.trim(); + if level_name.is_empty() { + level_label + } else { + format!("{level_label} · {level_name}") + } +} + +fn resolve_puzzle_current_owner_user_id(ctx: &TxContext, profile_id: &str) -> Option { + ctx.db + .puzzle_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .map(|row| row.owner_user_id) +} + +fn puzzle_archive_summary_text(status: PuzzleRuntimeLevelStatus) -> String { + match status { + PuzzleRuntimeLevelStatus::Cleared => "关卡已完成", + PuzzleRuntimeLevelStatus::Failed => "关卡失败", + PuzzleRuntimeLevelStatus::Playing => "拼图进行中", + } + .to_string() +} + +fn accrue_puzzle_point_incentive( + ctx: &TxContext, + profile_id: &str, + player_user_id: &str, + spent_points: u64, + updated_at_micros: i64, +) -> Result<(), String> { + if spent_points == 0 { + return Ok(()); + } + + let Some(row) = ctx + .db + .puzzle_work_profile() + .profile_id() + .find(&profile_id.to_string()) + else { + return Ok(()); + }; + if row.publication_status != PuzzlePublicationStatus::Published + || row.owner_user_id == player_user_id + { + return Ok(()); + } + + replace_puzzle_work_profile( + ctx, + &row, + PuzzleWorkProfileRow { + profile_id: row.profile_id.clone(), + work_id: row.work_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + work_title: row.work_title.clone(), + work_description: row.work_description.clone(), + level_name: row.level_name.clone(), + summary: row.summary.clone(), + theme_tags_json: row.theme_tags_json.clone(), + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + levels_json: row.levels_json.clone(), + publication_status: row.publication_status, + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, + point_incentive_total_half_points: + module_puzzle::puzzle_point_incentive_total_after_spend( + row.point_incentive_total_half_points, + spent_points, + ), + point_incentive_claimed_points: row.point_incentive_claimed_points, + anchor_pack_json: row.anchor_pack_json.clone(), + publish_ready: row.publish_ready, + created_at: row.created_at, + updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), + published_at: row.published_at, + }, + ); + Ok(()) } fn increment_puzzle_profile_play_count( @@ -1691,13 +2886,20 @@ fn increment_puzzle_profile_play_count( owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), + work_title: row.work_title.clone(), + work_description: row.work_description.clone(), level_name: row.level_name.clone(), summary: row.summary.clone(), theme_tags_json: row.theme_tags_json.clone(), cover_image_src: row.cover_image_src.clone(), cover_asset_id: row.cover_asset_id.clone(), + levels_json: row.levels_json.clone(), publication_status: row.publication_status, play_count: row.play_count.saturating_add(1), + remix_count: row.remix_count, + like_count: row.like_count, + point_incentive_total_half_points: row.point_incentive_total_half_points, + point_incentive_claimed_points: row.point_incentive_claimed_points, anchor_pack_json: row.anchor_pack_json.clone(), publish_ready: row.publish_ready, created_at: row.created_at, @@ -1730,11 +2932,11 @@ fn upsert_puzzle_profile_played_work( } fn replace_generated_candidate( - draft: &mut PuzzleResultDraft, + candidates_slot: &mut Vec, candidates: Vec, ) { // 结果页生图采用单图替换:每次只保留最新图片,并立即作为正式图。 - draft.candidates = candidates + *candidates_slot = candidates .into_iter() .take(1) .map(|mut candidate| { @@ -1753,14 +2955,33 @@ fn list_published_puzzle_profiles(ctx: &TxContext) -> Result Result<(), String> { +fn reset_next_level_handoff(run: &mut PuzzleRunSnapshot) { + run.recommended_next_profile_id = None; + run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_NONE.to_string(); + run.next_level_profile_id = None; + run.next_level_id = None; + run.recommended_next_works = Vec::new(); +} + +fn build_recommended_next_work( + current_profile: &PuzzleWorkProfile, + candidate: &PuzzleWorkProfile, +) -> PuzzleRecommendedNextWork { + PuzzleRecommendedNextWork { + profile_id: candidate.profile_id.clone(), + level_name: candidate.level_name.clone(), + author_display_name: candidate.author_display_name.clone(), + theme_tags: candidate.theme_tags.clone(), + cover_image_src: candidate.cover_image_src.clone(), + similarity_score: tag_similarity_score(¤t_profile.theme_tags, &candidate.theme_tags), + } +} + +fn refresh_next_level_handoff(ctx: &TxContext, run: &mut PuzzleRunSnapshot) -> Result<(), String> { let current_level = match run.current_level.as_ref() { Some(value) => value, None => { - run.recommended_next_profile_id = None; + reset_next_level_handoff(run); return Ok(()); } }; @@ -1771,12 +2992,41 @@ fn refresh_next_profile_recommendation( .find(¤t_level.profile_id) .ok_or_else(|| "当前拼图作品不存在".to_string())?, )?; - run.recommended_next_profile_id = select_next_profile( - ¤t_profile, - &run.played_profile_ids, - &list_published_puzzle_profiles(ctx)?, - ) - .map(|value| value.profile_id.clone()); + if current_level.status != PuzzleRuntimeLevelStatus::Cleared { + reset_next_level_handoff(run); + return Ok(()); + } + + if let Some(next_level) = + selected_profile_level_after_runtime_level(¤t_profile, current_level) + { + run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_SAME_WORK.to_string(); + run.next_level_profile_id = Some(current_profile.profile_id.clone()); + run.next_level_id = Some(next_level.level_id); + run.recommended_next_profile_id = Some(current_profile.profile_id.clone()); + run.recommended_next_works = Vec::new(); + return Ok(()); + } + + let candidates = list_published_puzzle_profiles(ctx)?; + let recommended_next_works = + select_next_profiles(¤t_profile, &run.played_profile_ids, &candidates, 3) + .into_iter() + .map(|candidate| build_recommended_next_work(¤t_profile, candidate)) + .collect::>(); + + if recommended_next_works.is_empty() { + reset_next_level_handoff(run); + return Ok(()); + } + + run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS.to_string(); + run.next_level_profile_id = recommended_next_works + .first() + .map(|candidate| candidate.profile_id.clone()); + run.next_level_id = None; + run.recommended_next_profile_id = run.next_level_profile_id.clone(); + run.recommended_next_works = recommended_next_works; Ok(()) } @@ -1898,7 +3148,11 @@ fn deserialize_anchor_pack(value: &str) -> Result { fn deserialize_optional_draft(value: &Option) -> Result, String> { value .as_ref() - .map(|raw| json_from_str(raw).map_err(|error| format!("拼图 draft JSON 非法: {error}"))) + .map(|raw| { + json_from_str(raw) + .map(normalize_puzzle_draft) + .map_err(|error| format!("拼图 draft JSON 非法: {error}")) + }) .transpose() } @@ -1910,6 +3164,22 @@ fn deserialize_theme_tags(value: &str) -> Result, String> { json_from_str(value).map_err(|error| format!("拼图 theme_tags JSON 非法: {error}")) } +fn deserialize_levels_json(value: &str) -> Result, String> { + if value.trim().is_empty() { + return Ok(Vec::new()); + } + json_from_str(value).map_err(|error| format!("拼图 levels JSON 非法: {error}")) +} + +fn deserialize_optional_levels_input( + value: Option<&str>, +) -> Result>, String> { + value + .map(|raw| deserialize_levels_json(raw)) + .transpose() + .map(|levels| levels.filter(|items| !items.is_empty())) +} + fn deserialize_run(value: &str) -> Result { json_from_str(value).map_err(|error| format!("拼图 run snapshot JSON 非法: {error}")) } @@ -1918,8 +3188,8 @@ fn deserialize_run(value: &str) -> Result { mod tests { use super::*; use module_puzzle::{ - PuzzleLeaderboardEntry, build_generated_candidates, empty_anchor_pack, - recommendation_score, tag_similarity_score, + PuzzleLeaderboardEntry, build_generated_candidates, compile_result_draft, + empty_anchor_pack, recommendation_score, tag_similarity_score, }; #[test] @@ -1934,6 +3204,10 @@ mod tests { previous_level_tags: vec!["蒸汽城市".to_string()], current_level: None, recommended_next_profile_id: None, + next_level_mode: PUZZLE_NEXT_LEVEL_MODE_NONE.to_string(), + next_level_profile_id: None, + next_level_id: None, + recommended_next_works: Vec::new(), leaderboard_entries: Vec::new(), }; let serialized = serialize_json(&snapshot); @@ -1949,6 +3223,20 @@ mod tests { .expect("candidates should build"); let draft = apply_selected_candidate( PuzzleResultDraft { + levels: vec![module_puzzle::PuzzleDraftLevel { + level_id: "puzzle-level-1".to_string(), + level_name: draft.level_name.clone(), + picture_description: draft + .levels + .first() + .map(|level| level.picture_description.clone()) + .unwrap_or_default(), + candidates: candidates.clone(), + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + }], candidates, ..draft }, @@ -1974,7 +3262,7 @@ mod tests { }]; replace_generated_candidate( - &mut draft, + &mut draft.candidates, vec![PuzzleGeneratedImageCandidate { candidate_id: "session-1-candidate-2".to_string(), image_src: "/generated-puzzle-assets/session-1/new/cover.png".to_string(), @@ -1999,15 +3287,23 @@ mod tests { owner_user_id: "owner-a".to_string(), source_session_id: None, author_display_name: "作者".to_string(), + work_title: "A".to_string(), + work_description: String::new(), level_name: "A".to_string(), summary: String::new(), theme_tags: vec!["雨夜".to_string(), "猫咪".to_string()], cover_image_src: Some("/a.png".to_string()), cover_asset_id: Some("asset-a".to_string()), + levels: Vec::new(), publication_status: PuzzlePublicationStatus::Published, updated_at_micros: 1, published_at_micros: Some(1), play_count: 0, + recent_play_count_7d: 0, + remix_count: 0, + like_count: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, publish_ready: true, anchor_pack: empty_anchor_pack(), }; @@ -2015,14 +3311,22 @@ mod tests { owner_user_id: "owner-a".to_string(), profile_id: "profile-b".to_string(), work_id: "work-b".to_string(), + work_title: "B".to_string(), + work_description: String::new(), level_name: "B".to_string(), theme_tags: vec!["雨夜".to_string(), "蒸汽城市".to_string()], cover_image_src: Some("/b.png".to_string()), cover_asset_id: Some("asset-b".to_string()), + levels: Vec::new(), publication_status: PuzzlePublicationStatus::Published, updated_at_micros: 2, published_at_micros: Some(2), play_count: 0, + recent_play_count_7d: 0, + remix_count: 0, + like_count: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, publish_ready: true, anchor_pack: empty_anchor_pack(), source_session_id: None, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 426e8a56..63b8f20f 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -1,5 +1,10 @@ use crate::*; +const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000; +const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7; +const PROFILE_REFERRAL_INVITED_USERS_LIMIT: usize = 20; +const PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX: &str = "new-user-registration"; + #[spacetimedb::table(accessor = profile_dashboard_state)] pub struct ProfileDashboardState { #[primary_key] @@ -67,6 +72,7 @@ pub struct ProfileInviteCode { pub(crate) user_id: String, #[unique] pub(crate) invite_code: String, + pub(crate) metadata_json: String, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, } @@ -116,6 +122,42 @@ pub struct ProfilePlayedWorld { pub(crate) last_observed_play_time_ms: u64, } +#[spacetimedb::table( + accessor = public_work_play_daily_stat, + index( + accessor = by_public_work_play_daily_stat_work_day, + btree(columns = [source_type, profile_id, played_day]) + ) +)] +pub struct PublicWorkPlayDailyStat { + #[primary_key] + pub(crate) stat_id: String, + // 中文注释:source_type 区分 custom-world / puzzle / big-fish,避免不同玩法 profile_id 撞桶。 + pub(crate) source_type: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + // 中文注释:UTC 自 Unix 纪元起的自然日桶,用于快速聚合近 7 日新增游玩次数。 + pub(crate) played_day: i64, + pub(crate) play_count: u32, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = public_work_like, + index(accessor = by_public_work_like_work, btree(columns = [source_type, profile_id])), + index(accessor = by_public_work_like_user, btree(columns = [user_id])) +)] +pub struct PublicWorkLike { + #[primary_key] + pub(crate) like_id: String, + // 中文注释:source_type 与 play 统计保持同一套作品类型命名,确保跨玩法 profile_id 不会互相冲突。 + pub(crate) source_type: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) user_id: String, + pub(crate) liked_at: Timestamp, +} + pub(crate) struct ProfilePlayedWorkUpsertInput { pub(crate) user_id: String, pub(crate) world_key: String, @@ -127,6 +169,37 @@ pub(crate) struct ProfilePlayedWorkUpsertInput { pub(crate) played_at_micros: i64, } +pub(crate) struct PublicWorkPlayRecordInput { + pub(crate) source_type: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) played_at_micros: i64, +} + +pub(crate) struct PublicWorkLikeRecordInput { + pub(crate) source_type: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) user_id: String, + pub(crate) liked_at_micros: i64, +} + +pub(crate) struct ProfileSaveArchiveUpsertInput { + pub(crate) user_id: String, + pub(crate) world_key: String, + pub(crate) owner_user_id: Option, + pub(crate) profile_id: Option, + pub(crate) world_type: Option, + pub(crate) world_name: String, + pub(crate) subtitle: String, + pub(crate) summary_text: String, + pub(crate) cover_image_src: Option, + pub(crate) bottom_tab: String, + pub(crate) game_state_json: String, + pub(crate) current_story_json: Option, + pub(crate) saved_at_micros: i64, +} + #[spacetimedb::table(accessor = profile_membership)] pub struct ProfileMembership { #[primary_key] @@ -282,6 +355,26 @@ pub fn list_profile_wallet_ledger( } } +// 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。 +#[spacetimedb::procedure] +pub fn grant_new_user_registration_wallet_reward( + ctx: &mut ProcedureContext, + input: RuntimeProfileDashboardGetInput, +) -> RuntimeProfileWalletAdjustmentProcedureResult { + match ctx.try_with_tx(|tx| grant_new_user_registration_wallet_reward_tx(tx, input.clone())) { + Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileWalletAdjustmentProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + // 资产生成由 Axum 调用外部模型,钱包扣费必须先在 SpacetimeDB 内原子落账。 #[spacetimedb::procedure] pub fn consume_profile_wallet_points_and_return( @@ -420,7 +513,7 @@ pub fn get_profile_referral_invite_center( } } -// 填码绑定、每日邀请者奖励上限和双方叙世币发放都在同一事务内完成。 +// 填码绑定、每日邀请者奖励上限和双方光点发放都在同一事务内完成。 #[spacetimedb::procedure] pub fn redeem_profile_referral_invite_code( ctx: &mut ProcedureContext, @@ -498,6 +591,25 @@ pub fn admin_disable_profile_redeem_code( } } +#[spacetimedb::procedure] +pub fn admin_upsert_profile_invite_code( + ctx: &mut ProcedureContext, + input: RuntimeProfileInviteCodeAdminUpsertInput, +) -> RuntimeProfileInviteCodeAdminProcedureResult { + match ctx.try_with_tx(|tx| admin_upsert_profile_invite_code_record(tx, input.clone())) { + Ok(record) => RuntimeProfileInviteCodeAdminProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileInviteCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + pub(crate) fn list_profile_save_archive_rows( ctx: &ReducerContext, input: RuntimeProfileSaveArchiveListInput, @@ -705,6 +817,172 @@ pub(crate) fn add_profile_observed_play_time( Ok(()) } +pub(crate) fn upsert_profile_save_archive( + ctx: &ReducerContext, + input: ProfileSaveArchiveUpsertInput, +) -> Result<(), String> { + let user_id = input.user_id.trim(); + let world_key = input.world_key.trim(); + if user_id.is_empty() || world_key.is_empty() { + return Err("profile_save_archive 参数不能为空".to_string()); + } + + let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); + let archive_id = format!("{user_id}:{world_key}"); + let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id); + let created_at = existing + .as_ref() + .map(|row| row.created_at) + .unwrap_or(saved_at); + + if let Some(existing) = existing { + ctx.db + .profile_save_archive() + .archive_id() + .delete(&existing.archive_id); + } + + ctx.db.profile_save_archive().insert(ProfileSaveArchive { + archive_id, + user_id: user_id.to_string(), + world_key: world_key.to_string(), + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + world_type: input.world_type, + world_name: input.world_name, + subtitle: input.subtitle, + summary_text: input.summary_text, + cover_image_src: input.cover_image_src, + saved_at, + bottom_tab: input.bottom_tab, + game_state_json: input.game_state_json, + current_story_json: input.current_story_json, + created_at, + updated_at: saved_at, + }); + + Ok(()) +} + +pub(crate) fn record_public_work_play( + ctx: &ReducerContext, + input: PublicWorkPlayRecordInput, +) -> Result<(), String> { + let source_type = input.source_type.trim(); + let owner_user_id = input.owner_user_id.trim(); + let profile_id = input.profile_id.trim(); + if source_type.is_empty() || owner_user_id.is_empty() || profile_id.is_empty() { + return Err("public_work_play_daily_stat 参数不能为空".to_string()); + } + + let played_day = public_work_play_day_from_micros(input.played_at_micros); + let stat_id = build_public_work_play_daily_stat_id(source_type, profile_id, played_day); + let updated_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); + let next_count = ctx + .db + .public_work_play_daily_stat() + .stat_id() + .find(&stat_id) + .map(|existing| { + ctx.db + .public_work_play_daily_stat() + .stat_id() + .delete(&existing.stat_id); + existing.play_count.saturating_add(1) + }) + .unwrap_or(1); + + ctx.db + .public_work_play_daily_stat() + .insert(PublicWorkPlayDailyStat { + stat_id, + source_type: source_type.to_string(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + played_day, + play_count: next_count, + updated_at, + }); + + Ok(()) +} + +pub(crate) fn record_public_work_like( + ctx: &ReducerContext, + input: PublicWorkLikeRecordInput, +) -> Result { + let source_type = input.source_type.trim(); + let owner_user_id = input.owner_user_id.trim(); + let profile_id = input.profile_id.trim(); + let user_id = input.user_id.trim(); + if source_type.is_empty() + || owner_user_id.is_empty() + || profile_id.is_empty() + || user_id.is_empty() + { + return Err("public_work_like 参数不能为空".to_string()); + } + + let like_id = build_public_work_like_id(source_type, profile_id, user_id); + if ctx.db.public_work_like().like_id().find(&like_id).is_some() { + return Ok(false); + } + + ctx.db.public_work_like().insert(PublicWorkLike { + like_id, + source_type: source_type.to_string(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + user_id: user_id.to_string(), + liked_at: Timestamp::from_micros_since_unix_epoch(input.liked_at_micros), + }); + + Ok(true) +} + +pub(crate) fn count_recent_public_work_plays( + ctx: &ReducerContext, + source_type: &str, + profile_id: &str, + now_micros: i64, +) -> u32 { + let source_type = source_type.trim(); + let profile_id = profile_id.trim(); + if source_type.is_empty() || profile_id.is_empty() { + return 0; + } + + let current_day = public_work_play_day_from_micros(now_micros); + let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1); + + ctx.db + .public_work_play_daily_stat() + .iter() + .filter(|row| { + row.source_type == source_type + && row.profile_id == profile_id + && row.played_day >= first_day + && row.played_day <= current_day + }) + .fold(0u32, |total, row| total.saturating_add(row.play_count)) +} + +fn public_work_play_day_from_micros(value: i64) -> i64 { + value.div_euclid(PUBLIC_WORK_PLAY_DAY_MICROS) +} + +fn build_public_work_play_daily_stat_id( + source_type: &str, + profile_id: &str, + played_day: i64, +) -> String { + format!("{source_type}:{profile_id}:{played_day}") +} + +fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str) -> String { + format!("{source_type}:{profile_id}:{user_id}") +} + fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) { if ctx .db @@ -785,12 +1063,20 @@ fn sync_profile_dashboard_from_snapshot( .as_ref() .map(|row| row.total_play_time_ms) .unwrap_or(0); - let next_wallet_balance = module_runtime::read_runtime_json_non_negative_u64( - game_state.and_then(|state| state.get("playerCurrency")), - ); + let has_business_wallet_ledger = has_profile_business_wallet_ledger(ctx, &snapshot.user_id); + let synced_wallet_balance = if has_business_wallet_ledger { + None + } else { + game_state + .and_then(|state| state.get("playerCurrency")) + .map(|value| module_runtime::read_runtime_json_non_negative_u64(Some(value))) + }; + let next_wallet_balance = synced_wallet_balance.unwrap_or(previous_wallet_balance); let mut next_total_play_time_ms = previous_total_play_time_ms; - if next_wallet_balance != previous_wallet_balance { + if let Some(next_wallet_balance) = synced_wallet_balance + && next_wallet_balance != previous_wallet_balance + { ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { wallet_ledger_id: build_runtime_profile_snapshot_wallet_ledger_id( &snapshot.user_id, @@ -965,6 +1251,174 @@ pub(crate) fn build_profile_save_archive_snapshot_from_row( } } +fn read_string_from_json(value: Option<&JsonValue>) -> Option { + value + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn resolve_profile_world_snapshot_meta( + game_state: Option<&serde_json::Map>, +) -> Option { + let game_state = game_state?; + let custom_world_profile = game_state + .get("customWorldProfile") + .and_then(JsonValue::as_object); + + if let Some(custom_world_profile) = custom_world_profile { + let profile_id = read_string_from_json(custom_world_profile.get("id")); + let world_title = read_string_from_json(custom_world_profile.get("name")) + .or_else(|| read_string_from_json(custom_world_profile.get("title"))); + if profile_id.is_some() || world_title.is_some() { + let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string()); + return Some(RuntimeProfileWorldSnapshotMeta { + world_key: profile_id + .as_ref() + .map(|profile_id| format!("custom:{profile_id}")) + .unwrap_or_else(|| format!("custom:{world_title}")), + owner_user_id: None, + profile_id, + world_type: Some("CUSTOM".to_string()), + world_title, + world_subtitle: read_string_from_json(custom_world_profile.get("summary")) + .or_else(|| read_string_from_json(custom_world_profile.get("settingText"))) + .unwrap_or_default(), + }); + } + } + + let world_type = read_string_from_json(game_state.get("worldType"))?; + let current_scene_preset = game_state + .get("currentScenePreset") + .and_then(JsonValue::as_object); + + Some(RuntimeProfileWorldSnapshotMeta { + world_key: format!("builtin:{world_type}"), + owner_user_id: None, + profile_id: None, + world_type: Some(world_type.clone()), + world_title: current_scene_preset + .and_then(|preset| read_string_from_json(preset.get("name"))) + .unwrap_or_else(|| build_builtin_world_title(&world_type)), + world_subtitle: current_scene_preset + .and_then(|preset| { + read_string_from_json(preset.get("summary")) + .or_else(|| read_string_from_json(preset.get("description"))) + }) + .unwrap_or_default(), + }) +} + +fn resolve_profile_save_archive_meta( + game_state: &JsonValue, + current_story_json: Option<&str>, +) -> Option { + if is_non_persistent_runtime_snapshot(game_state) { + return None; + } + + let game_state_object = game_state.as_object(); + let world_meta = resolve_profile_world_snapshot_meta(game_state_object)?; + let story_engine_memory = game_state_object + .and_then(|state| state.get("storyEngineMemory")) + .and_then(JsonValue::as_object); + let continue_game_digest = story_engine_memory + .and_then(|memory| read_string_from_json(memory.get("continueGameDigest"))); + let current_story_text = parse_optional_json_str(current_story_json) + .ok() + .flatten() + .and_then(|story| story.as_object().cloned()) + .and_then(|story| read_string_from_json(story.get("text"))); + let custom_world_profile = game_state_object + .and_then(|state| state.get("customWorldProfile")) + .and_then(JsonValue::as_object); + + if let Some(custom_world_profile) = custom_world_profile { + let world_name = read_string_from_json(custom_world_profile.get("name")) + .or_else(|| read_string_from_json(custom_world_profile.get("title"))) + .unwrap_or_else(|| world_meta.world_title.clone()); + let subtitle = read_string_from_json(custom_world_profile.get("summary")) + .or_else(|| read_string_from_json(custom_world_profile.get("settingText"))) + .unwrap_or_else(|| world_meta.world_subtitle.clone()); + let summary_text = continue_game_digest + .or(current_story_text) + .or_else(|| { + if subtitle.is_empty() { + None + } else { + Some(subtitle.clone()) + } + }) + .unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string()); + + return Some(RuntimeProfileSaveArchiveMeta { + world_key: world_meta.world_key, + owner_user_id: world_meta.owner_user_id, + profile_id: world_meta.profile_id, + world_type: world_meta.world_type, + world_name, + subtitle, + summary_text, + cover_image_src: read_string_from_json(custom_world_profile.get("coverImageSrc")), + }); + } + + let summary_text = continue_game_digest + .or(current_story_text) + .or_else(|| { + if world_meta.world_subtitle.is_empty() { + None + } else { + Some(world_meta.world_subtitle.clone()) + } + }) + .unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string()); + let current_scene_preset = game_state_object + .and_then(|state| state.get("currentScenePreset")) + .and_then(JsonValue::as_object); + + Some(RuntimeProfileSaveArchiveMeta { + world_key: world_meta.world_key, + owner_user_id: world_meta.owner_user_id, + profile_id: world_meta.profile_id, + world_type: world_meta.world_type, + world_name: world_meta.world_title, + subtitle: world_meta.world_subtitle.clone(), + summary_text, + cover_image_src: current_scene_preset + .and_then(|preset| read_string_from_json(preset.get("imageSrc"))), + }) +} + +fn is_non_persistent_runtime_snapshot(game_state: &JsonValue) -> bool { + let Some(game_state) = game_state.as_object() else { + return false; + }; + + if game_state + .get("runtimePersistenceDisabled") + .and_then(JsonValue::as_bool) + .unwrap_or(false) + { + return true; + } + + matches!( + read_string_from_json(game_state.get("runtimeMode")).as_deref(), + Some("preview") | Some("test") + ) +} + +fn build_builtin_world_title(world_type: &str) -> String { + match world_type { + "WUXIA" => "武侠世界".to_string(), + "XIANXIA" => "仙侠世界".to_string(), + _ => "叙事世界".to_string(), + } +} + fn get_profile_dashboard_snapshot( ctx: &ReducerContext, input: RuntimeProfileDashboardGetInput, @@ -1215,10 +1669,14 @@ fn redeem_profile_referral_invite_code_record( ), bound_at, )?; - let today_inviter_reward_count = - count_today_profile_referral_inviter_rewards(ctx, &inviter_code.user_id, bound_at); - let inviter_reward_granted = - should_grant_runtime_profile_inviter_reward(today_inviter_reward_count); + let is_admin_invite_code = is_admin_profile_invite_code_user_id(&inviter_code.user_id); + let today_inviter_reward_count = if is_admin_invite_code { + 0 + } else { + count_today_profile_referral_inviter_rewards(ctx, &inviter_code.user_id, bound_at) + }; + let inviter_reward_granted = !is_admin_invite_code + && module_runtime::should_grant_runtime_profile_inviter_reward(today_inviter_reward_count); let inviter_balance_after = if inviter_reward_granted { apply_profile_wallet_delta( ctx, @@ -1410,6 +1868,56 @@ fn admin_disable_profile_redeem_code_record( Ok(build_profile_redeem_code_snapshot_from_row(&inserted)) } +fn admin_upsert_profile_invite_code_record( + ctx: &ReducerContext, + input: RuntimeProfileInviteCodeAdminUpsertInput, +) -> Result { + let validated_input = build_runtime_profile_invite_code_admin_upsert_input( + input.admin_user_id, + input.invite_code, + input.metadata_json, + input.updated_at_micros, + ) + .map_err(|error| error.to_string())?; + let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); + let user_id = build_admin_profile_invite_code_user_id( + &validated_input.admin_user_id, + &validated_input.invite_code, + ); + + if let Some(existing) = ctx + .db + .profile_invite_code() + .invite_code() + .find(&validated_input.invite_code) + { + if existing.user_id != user_id { + return Err("邀请码已被其他用户占用".to_string()); + } + ctx.db + .profile_invite_code() + .user_id() + .delete(&existing.user_id); + let inserted = ctx.db.profile_invite_code().insert(ProfileInviteCode { + user_id, + invite_code: validated_input.invite_code, + metadata_json: validated_input.metadata_json, + created_at: existing.created_at, + updated_at, + }); + return Ok(build_profile_invite_code_snapshot_from_row(&inserted)); + } + + let inserted = ctx.db.profile_invite_code().insert(ProfileInviteCode { + user_id, + invite_code: validated_input.invite_code, + metadata_json: validated_input.metadata_json, + created_at: updated_at, + updated_at, + }); + Ok(build_profile_invite_code_snapshot_from_row(&inserted)) +} + fn build_profile_referral_invite_center_snapshot( ctx: &ReducerContext, user_id: &str, @@ -1445,6 +1953,7 @@ fn build_profile_referral_invite_center_snapshot( today_inviter_reward_remaining: PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT .saturating_sub(today_inviter_reward_count), reward_points: PROFILE_REFERRAL_REWARD_POINTS, + invited_users: list_profile_referral_invited_users(ctx, user_id), has_redeemed_code: bound_relation.is_some(), bound_inviter_user_id: bound_relation .as_ref() @@ -1456,6 +1965,50 @@ fn build_profile_referral_invite_center_snapshot( } } +fn list_profile_referral_invited_users( + ctx: &ReducerContext, + inviter_user_id: &str, +) -> Vec { + // 中文注释:邀请面板只展示最近成功邀请用户,完整统计仍由计数字段承担。 + let inviter_user_id = inviter_user_id.to_string(); + let mut relations = ctx + .db + .profile_referral_relation() + .by_profile_referral_inviter_user_id() + .filter(&inviter_user_id) + .collect::>(); + + relations.sort_by(|left, right| { + right + .bound_at + .to_micros_since_unix_epoch() + .cmp(&left.bound_at.to_micros_since_unix_epoch()) + }); + + relations + .into_iter() + .take(PROFILE_REFERRAL_INVITED_USERS_LIMIT) + .map(|relation| { + let account = ctx + .db + .user_account() + .user_id() + .find(&relation.invitee_user_id); + RuntimeReferralInvitedUserSnapshot { + user_id: relation.invitee_user_id, + display_name: account + .as_ref() + .map(|user| user.display_name.trim()) + .filter(|name| !name.is_empty()) + .unwrap_or("玩家") + .to_string(), + avatar_url: account.and_then(|user| user.avatar_url), + bound_at_micros: relation.bound_at.to_micros_since_unix_epoch(), + } + }) + .collect() +} + fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInviteCode { if let Some(row) = ctx .db @@ -1482,6 +2035,7 @@ fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInv ctx.db.profile_invite_code().insert(ProfileInviteCode { user_id: user_id.to_string(), invite_code, + metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(), created_at: ctx.timestamp, updated_at: ctx.timestamp, }) @@ -1504,6 +2058,14 @@ fn count_today_profile_referral_inviter_rewards( .count() as u32 } +fn is_admin_profile_invite_code_user_id(user_id: &str) -> bool { + user_id.starts_with("admin:") +} + +fn build_admin_profile_invite_code_user_id(admin_user_id: &str, invite_code: &str) -> String { + format!("admin:{}:{}", admin_user_id, invite_code) +} + fn profile_wallet_balance(ctx: &ReducerContext, user_id: &str) -> u64 { ctx.db .profile_dashboard_state() @@ -1513,6 +2075,42 @@ fn profile_wallet_balance(ctx: &ReducerContext, user_id: &str) -> u64 { .unwrap_or(0) } +fn build_new_user_registration_wallet_ledger_id(user_id: &str) -> String { + format!("{PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX}:{user_id}") +} + +fn grant_new_user_registration_wallet_reward_tx( + ctx: &ReducerContext, + input: RuntimeProfileDashboardGetInput, +) -> Result { + let validated_input = build_runtime_profile_dashboard_get_input(input.user_id) + .map_err(|error| error.to_string())?; + let ledger_id = build_new_user_registration_wallet_ledger_id(&validated_input.user_id); + if ctx + .db + .profile_wallet_ledger() + .wallet_ledger_id() + .find(&ledger_id) + .is_none() + { + apply_profile_wallet_delta( + ctx, + &validated_input.user_id, + PROFILE_NEW_USER_INITIAL_WALLET_POINTS, + RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward, + &ledger_id, + ctx.timestamp, + )?; + } + + get_profile_dashboard_snapshot( + ctx, + RuntimeProfileDashboardGetInput { + user_id: validated_input.user_id, + }, + ) +} + fn build_profile_recharge_center_snapshot( ctx: &ReducerContext, user_id: &str, @@ -1645,6 +2243,24 @@ fn apply_profile_wallet_delta( ) } +pub(crate) fn grant_profile_wallet_points( + ctx: &ReducerContext, + user_id: &str, + amount_delta: u64, + source_type: RuntimeProfileWalletLedgerSourceType, + ledger_id: &str, + created_at: Timestamp, +) -> Result { + apply_profile_wallet_delta( + ctx, + user_id, + amount_delta, + source_type, + ledger_id, + created_at, + ) +} + fn apply_profile_wallet_adjustment( ctx: &ReducerContext, input: RuntimeProfileWalletAdjustmentInput, @@ -1762,6 +2378,13 @@ fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool { }) } +fn has_profile_business_wallet_ledger(ctx: &ReducerContext, user_id: &str) -> bool { + ctx.db.profile_wallet_ledger().iter().any(|row| { + row.user_id == user_id + && row.source_type != RuntimeProfileWalletLedgerSourceType::SnapshotSync + }) +} + fn latest_profile_recharge_order( ctx: &ReducerContext, user_id: &str, @@ -1837,6 +2460,18 @@ fn build_profile_redeem_code_snapshot_from_row( } } +fn build_profile_invite_code_snapshot_from_row( + row: &ProfileInviteCode, +) -> RuntimeProfileInviteCodeSnapshot { + RuntimeProfileInviteCodeSnapshot { + user_id: row.user_id.clone(), + invite_code: row.invite_code.clone(), + metadata_json: row.metadata_json.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + fn build_profile_wallet_ledger_snapshot_from_row( row: &ProfileWalletLedger, ) -> RuntimeProfileWalletLedgerEntrySnapshot { diff --git a/spacetime.json b/spacetime.json index fc6b20a0..cb013ee4 100644 --- a/spacetime.json +++ b/spacetime.json @@ -1,4 +1,4 @@ { - "server": "http://127.0.0.1:3101", + "server": "local", "module-path": "./server-rs/crates/spacetime-module" } diff --git a/src/App.tsx b/src/App.tsx index 13109dc0..5ede559e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,11 @@ -import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; +import { + lazy, + Suspense, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { useAuthUi } from './components/auth/AuthUiContext'; import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell'; @@ -133,7 +140,7 @@ export default function App() { return (
(buildInitialRun); + const authorityRunRef = useRef(run); + + const syncRun = useCallback((nextRun: Match3DRunSnapshot) => { + setRun(nextRun); + }, []); + + const handleClickItem = useCallback(async (payload: Match3DClickItemRequest) => { + const result = await confirmLocalMatch3DClick(authorityRunRef.current, payload); + authorityRunRef.current = result.run; + setRun(result.run); + return result; + }, []); + + const handleRestart = useCallback(() => { + const nextRun = buildInitialRun(); + authorityRunRef.current = nextRun; + setRun(nextRun); + }, []); + + const handleExit = useCallback(() => { + window.location.assign('/'); + }, []); + + const handleTimeExpired = useCallback(() => { + const nextRun = resolveLocalMatch3DTimer(authorityRunRef.current); + authorityRunRef.current = nextRun; + setRun(nextRun); + }, []); + + return ( + + ); +} diff --git a/src/PuzzlePlaygroundApp.tsx b/src/PuzzlePlaygroundApp.tsx index 57bc8aca..d5794bfd 100644 --- a/src/PuzzlePlaygroundApp.tsx +++ b/src/PuzzlePlaygroundApp.tsx @@ -7,8 +7,10 @@ import type { import type { PuzzleWorkSummary } from '../packages/shared/src/contracts/puzzleWorkSummary'; import { PuzzleRuntimeShell } from './components/puzzle-runtime/PuzzleRuntimeShell'; import { + applyLocalPuzzleFreezeTime, advanceLocalPuzzleLevel, dragLocalPuzzlePiece, + setLocalPuzzlePaused, startLocalPuzzleRun, swapLocalPuzzlePieces, } from './services/puzzle-runtime/puzzleLocalRuntime'; @@ -16,7 +18,7 @@ import { const PLACEHOLDER_PUZZLE_IMAGE = 'data:image/svg+xml;utf8,' + encodeURIComponent(` - + @@ -28,13 +30,13 @@ const PLACEHOLDER_PUZZLE_IMAGE = - - - - - - - + + + + + + + `); function buildPlaceholderPuzzleWork(): PuzzleWorkSummary { @@ -53,6 +55,7 @@ function buildPlaceholderPuzzleWork(): PuzzleWorkSummary { updatedAt: new Date(0).toISOString(), publishedAt: new Date(0).toISOString(), playCount: 0, + likeCount: 0, publishReady: true, }; } @@ -77,6 +80,20 @@ export default function PuzzlePlaygroundApp() { setRun((currentRun) => advanceLocalPuzzleLevel(currentRun)); }; + const handlePauseChange = async (paused: boolean) => { + setRun((currentRun) => setLocalPuzzlePaused(currentRun, paused)); + }; + + const handleUseProp = async ( + propKind: 'hint' | 'reference' | 'freezeTime', + ) => { + setRun((currentRun) => + propKind === 'freezeTime' + ? applyLocalPuzzleFreezeTime(currentRun) + : setLocalPuzzlePaused(currentRun, propKind === 'reference'), + ); + }; + return ( ); } diff --git a/src/components/AdventureEntityModal.tsx b/src/components/AdventureEntityModal.tsx index 81fb8c2a..cf275df8 100644 --- a/src/components/AdventureEntityModal.tsx +++ b/src/components/AdventureEntityModal.tsx @@ -1,4 +1,3 @@ -import { X } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import type { ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; @@ -88,6 +87,7 @@ import { InventoryItemGrid, } from './InventoryItemViews'; import { MedievalNpcAnimator } from './MedievalNpcAnimator'; +import { PixelCloseButton } from './PixelCloseButton'; import { ResolvedAssetImage } from './ResolvedAssetImage'; import { SkillEffectPreview } from './SkillEffectPreview'; @@ -957,8 +957,8 @@ export function AdventureEntityModal({ style={getNineSliceStyle(UI_CHROME.modalPanel)} onClick={(event) => event.stopPropagation()} > -
-
+
+
详情
@@ -975,13 +975,7 @@ export function AdventureEntityModal({ /> ) : null}
- +
@@ -1319,13 +1313,10 @@ export function AdventureEntityModal({ {detailCharacter.name}
- + label="关闭标签效果" + />
@@ -1431,13 +1422,10 @@ export function AdventureEntityModal({ {selectedSkillOwnerName}
- + label="关闭技能详情" + />
diff --git a/src/components/CharacterChatModal.tsx b/src/components/CharacterChatModal.tsx index 5892f8c5..101fe663 100644 --- a/src/components/CharacterChatModal.tsx +++ b/src/components/CharacterChatModal.tsx @@ -2,8 +2,8 @@ import { AnimatePresence, motion } from 'motion/react'; import { useEffect, useRef } from 'react'; import type { CharacterChatModalState } from '../hooks/rpg-runtime-story'; -import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets'; -import { PixelIcon } from './PixelIcon'; +import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; +import { PixelCloseButton } from './PixelCloseButton'; interface CharacterChatModalProps { modal: CharacterChatModalState | null; @@ -56,13 +56,11 @@ export function CharacterChatModal({ {modal.target.character.title} / {modal.target.roleLabel}
- + label="关闭角色聊天" + placement="inline" + />
diff --git a/src/components/CharacterDetailModal.tsx b/src/components/CharacterDetailModal.tsx index 896eda50..5c07d7bc 100644 --- a/src/components/CharacterDetailModal.tsx +++ b/src/components/CharacterDetailModal.tsx @@ -23,7 +23,6 @@ import { type WorldType, } from '../types'; import { - CHROME_ICONS, getNineSliceStyle, type NineSliceTexture, UI_CHROME, @@ -38,7 +37,7 @@ import { CharacterSkillsList, } from './CharacterInfoShared'; import { MedievalNpcAnimator } from './MedievalNpcAnimator'; -import { PixelIcon } from './PixelIcon'; +import { PixelCloseButton } from './PixelCloseButton'; interface CharacterDetailModalProps { character: Character | null; @@ -194,14 +193,7 @@ export function CharacterDetailModal({ {subtitle}
- +
diff --git a/src/components/CharacterPanel.tsx b/src/components/CharacterPanel.tsx index 20bc6383..919f9c27 100644 --- a/src/components/CharacterPanel.tsx +++ b/src/components/CharacterPanel.tsx @@ -1,7 +1,6 @@ import { AnimatePresence, motion } from 'motion/react'; import { useEffect, useMemo, useState } from 'react'; -import { normalizePlayerProgressionState } from '../data/playerProgression'; import { resolveAttributeSchema, resolveCharacterAttributeProfile, @@ -27,6 +26,7 @@ import { getEquipmentSlotLabel, } from '../data/equipmentEffects'; import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals'; +import { normalizePlayerProgressionState } from '../data/playerProgression'; import type { CharacterChatTarget } from '../hooks/rpg-runtime-story'; import { getResourceLabelsForWorld } from '../services/customWorldPresentation'; import { @@ -38,12 +38,10 @@ import { CustomWorldProfile, EquipmentLoadout, GameState, - QuestLogEntry, TimedBuildBuff, WorldType, } from '../types'; import { - CHROME_ICONS, getEquipmentSlotIcon, getNineSliceStyle, UI_CHROME, @@ -66,6 +64,7 @@ import { } from './CharacterInfoShared'; import type { GameCanvasEntitySelection } from './GameCanvas'; import { MedievalNpcAnimator } from './MedievalNpcAnimator'; +import { PixelCloseButton } from './PixelCloseButton'; import { PixelIcon } from './PixelIcon'; import { ResolvedAssetImage } from './ResolvedAssetImage'; @@ -82,7 +81,6 @@ interface CharacterPanelProps { activeBuildBuffs?: TimedBuildBuff[]; companionRenderStates: CompanionRenderState[]; npcStates?: GameState['npcStates']; - quests: QuestLogEntry[]; onOpenCamp?: () => void; onOpenCharacterChat?: (target: CharacterChatTarget) => void; chatSummaries?: Record; @@ -155,7 +153,6 @@ export function CharacterPanel({ activeBuildBuffs = [], companionRenderStates, npcStates = {}, - quests, onInspectMember, companionArcStates = [], companionResolutions = [], @@ -215,11 +212,6 @@ export function CharacterPanel({ [partyMembers, selectedMemberId], ); - const activeQuests = useMemo( - () => quests.filter((quest) => quest.status !== 'turned_in'), - [quests], - ); - const buildBreakdownByMemberId = useMemo( () => Object.fromEntries( @@ -374,29 +366,6 @@ export function CharacterPanel({ paddingY: 12, })} > - {activeQuests.length > 0 && ( -
-
- 褰撳墠濮旀墭 -
-
- {activeQuests.map((quest) => ( -
-
- {quest.title} -
-
- {quest.summary} -
-
- ))} -
-
- )} -
队伍成员
{partyMembers.map((member) => ( @@ -497,13 +466,10 @@ export function CharacterPanel({ {selectedMember.character.name}
- + label="关闭标签效果" + />
@@ -619,13 +585,10 @@ export function CharacterPanel({
- + label="关闭角色详情" + />
diff --git a/src/components/CompanionCampModal.tsx b/src/components/CompanionCampModal.tsx index 1ebe3515..926eda7f 100644 --- a/src/components/CompanionCampModal.tsx +++ b/src/components/CompanionCampModal.tsx @@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from 'react'; import { getCharacterById } from '../data/characterPresets'; import { MAX_COMPANIONS } from '../data/npcInteractions'; import { Character, CompanionState } from '../types'; -import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets'; -import { PixelIcon } from './PixelIcon'; +import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; +import { PixelCloseButton } from './PixelCloseButton'; import { ResolvedAssetImage } from './ResolvedAssetImage'; interface CompanionCampModalProps { @@ -145,13 +145,7 @@ export function CompanionCampModal({ {playerCharacter ? `${playerCharacter.name} / 出战 ${companions.length}/${MAX_COMPANIONS}` : '队伍调度'}
- +
diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 681948db..32e36d64 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -1099,7 +1099,7 @@ export function CustomWorldEntityCatalog({
{lockedCharacterNames.has(role.name.trim()) ? ( - 创作者锁定 + 百梦主锁定 ) : null} diff --git a/src/components/CustomWorldEntityEditorModal.test.tsx b/src/components/CustomWorldEntityEditorModal.test.tsx index a1a3d61a..08717ec0 100644 --- a/src/components/CustomWorldEntityEditorModal.test.tsx +++ b/src/components/CustomWorldEntityEditorModal.test.tsx @@ -88,13 +88,34 @@ vi.mock('./rpg-runtime-shell', () => ({ session, chrome, }: { - session: { gameState: { currentScenePreset?: { name?: string } | null } }; + session: { + gameState: { + currentScenePreset?: { id?: string; name?: string } | null; + playerCharacter?: { name?: string } | null; + runtimeSessionId?: string | null; + runtimeMode?: string; + runtimePersistenceDisabled?: boolean; + }; + currentStory?: { text?: string } | null; + }; chrome?: { hidePlayerLevelBadge?: boolean }; }) => (
幕预览运行时
{chrome?.hidePlayerLevelBadge ?
隐藏等级徽标
: null}
{session.gameState.currentScenePreset?.name ?? '未进入场景'}
+
{session.gameState.currentScenePreset?.id ?? '未进入场景ID'}
+
+ {session.gameState.playerCharacter ? '已选择预览角色' : '未选择角色'} +
+
{session.gameState.runtimeSessionId ?? '未设置预览会话'}
+
{session.gameState.runtimeMode ?? '未设置运行模式'}
+
+ {session.gameState.runtimePersistenceDisabled + ? '预览禁用持久化' + : '预览允许持久化'} +
+
{session.currentStory?.text ?? '未生成当前故事'}
), })); @@ -102,6 +123,30 @@ vi.mock('./rpg-runtime-shell', () => ({ vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({ fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }), saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined), + resolveCharacterRoleAssetWorkflow: vi.fn(({ role }) => + Promise.resolve({ + ok: true, + cache: null, + workflow: { + role, + defaultPromptBundle: { + visualPromptText: '', + animationPromptText: '', + scenePromptText: '', + }, + visualPromptText: '', + animationPromptText: '', + animationPromptTextByKey: {}, + visualDrafts: [], + selectedVisualDraftId: '', + selectedAnimation: 'idle', + }, + }), + ), + putCharacterRoleAssetWorkflow: vi.fn().mockResolvedValue({ + ok: true, + cache: null, + }), generateCharacterVisualCandidates: vi.fn(), publishCharacterVisualAsset: vi.fn(), generateCharacterAnimationDraft: vi.fn(), @@ -1312,6 +1357,13 @@ test('场景幕预览会打开当前幕运行时面板', async () => { expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0); expect(screen.getByText('隐藏等级徽标')).toBeTruthy(); + expect(screen.getByText('已选择预览角色')).toBeTruthy(); + expect(screen.getByText('runtime-scene-act-preview')).toBeTruthy(); + expect(screen.getByText('landmark-1')).toBeTruthy(); + expect(screen.getByText('play')).toBeTruthy(); + expect(screen.getByText('预览禁用持久化')).toBeTruthy(); + expect(screen.getByText(/顾潮音已经在沉钟栈桥等你/u)).toBeTruthy(); + expect(screen.queryByText('正在载入这一幕的游戏流程...')).toBeNull(); await user.click(screen.getByRole('button', { name: '结束预览' })); diff --git a/src/components/InventoryItemViews.tsx b/src/components/InventoryItemViews.tsx index 4ad39303..afecf70b 100644 --- a/src/components/InventoryItemViews.tsx +++ b/src/components/InventoryItemViews.tsx @@ -5,11 +5,11 @@ import { resolveInventoryItemUseEffect } from '../data/inventoryEffects'; import { buildInventoryItemDescription } from '../data/itemPresentation'; import type { Character, InventoryItem, WorldType } from '../types'; import { - CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME, } from '../uiAssets'; +import { PixelCloseButton } from './PixelCloseButton'; import { PixelIcon } from './PixelIcon'; function getInventoryRarityTheme(rarity: InventoryItem['rarity']) { @@ -185,13 +185,7 @@ export function InventoryItemDetailModal({ onClick={(event) => event.stopPropagation()} >
- +
Promise; onDismantleItem: (itemId: string) => Promise; onReforgeItem: (itemId: string) => Promise; - continueGameDigest?: string | null; narrativeCodex?: NarrativeCodexSection[]; narrativeQaReport?: NarrativeQaReport | null; } @@ -58,7 +57,6 @@ export function InventoryPanel({ onCraftRecipe, onDismantleItem: _onDismantleItem, onReforgeItem: _onReforgeItem, - continueGameDigest = null, narrativeCodex = [], narrativeQaReport = null, }: InventoryPanelProps) { @@ -92,14 +90,6 @@ export function InventoryPanel({ return (
- {continueGameDigest && ( -
-
- 旅程回顾 -
- {continueGameDigest} -
- )} 地图
- +
@@ -385,13 +380,10 @@ export function MapModal({
场景切换
{pendingScene.scene.name}
- + label="关闭场景切换" + />
diff --git a/src/components/NpcModals.tsx b/src/components/NpcModals.tsx index 0d209f93..3b991868 100644 --- a/src/components/NpcModals.tsx +++ b/src/components/NpcModals.tsx @@ -25,7 +25,8 @@ import { RuntimeNpcGiftItemView, RuntimeNpcTradeItemView, } from '../types'; -import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets'; +import { getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets'; +import { PixelCloseButton } from './PixelCloseButton'; import { PixelIcon } from './PixelIcon'; interface NpcModalsProps { @@ -232,13 +233,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) { {npcInteraction?.npcName ?? tradeModal.encounter.npcName} / 你当前{currencyName}:{npcInteraction?.playerCurrency ?? gameState.playerCurrency}
- + label="关闭交易" + placement="inline" + />
@@ -385,13 +384,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) { {tradeDetail.source === 'buy' ? '对方物品' : '背包物品'}
- + label="关闭物品详情" + placement="inline" + />
@@ -474,9 +471,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
赠送礼物
{npcUi.giftModal.encounter.npcName}
- +
@@ -550,9 +549,11 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
调整同行位置
队伍已满,请先选择一名当前同行角色离队,再邀请对方加入。
- +
diff --git a/src/components/PixelCloseButton.test.tsx b/src/components/PixelCloseButton.test.tsx new file mode 100644 index 00000000..9ab0ca12 --- /dev/null +++ b/src/components/PixelCloseButton.test.tsx @@ -0,0 +1,45 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { expect, test, vi } from 'vitest'; + +import { PixelCloseButton } from './PixelCloseButton'; + +test('pixel close button closes without bubbling to the overlay', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const onOverlayClick = vi.fn(); + + render( +
+ +
, + ); + + await user.click(screen.getByRole('button', { name: '关闭测试面板' })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onOverlayClick).not.toHaveBeenCalled(); +}); + +test('inline pixel close button keeps the same click boundary', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const onHeaderClick = vi.fn(); + + render( +
+ +
, + ); + + await user.click(screen.getByRole('button', { name: '关闭标题栏面板' })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onHeaderClick).not.toHaveBeenCalled(); +}); diff --git a/src/components/PixelCloseButton.tsx b/src/components/PixelCloseButton.tsx new file mode 100644 index 00000000..f9b6beb7 --- /dev/null +++ b/src/components/PixelCloseButton.tsx @@ -0,0 +1,45 @@ +import type { MouseEvent } from 'react'; + +import { CHROME_ICONS } from '../uiAssets'; +import { PixelIcon } from './PixelIcon'; + +type PixelCloseButtonProps = { + onClick: () => void; + label?: string; + placement?: 'absolute' | 'inline'; + className?: string; +}; + +/** + * RPG 像素风弹窗右上关闭按钮。 + * 统一拦截点击冒泡,避免历史手写 overlay / panel 的点击处理影响关闭行为。 + */ +export function PixelCloseButton({ + onClick, + label = '关闭面板', + placement = 'absolute', + className = '', +}: PixelCloseButtonProps) { + const handleClick = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onClick(); + }; + + const placementClassName = + placement === 'absolute' + ? 'absolute right-4 top-3 sm:right-5 sm:top-4' + : 'relative shrink-0'; + + return ( + + ); +} diff --git a/src/components/auth/AccountModal.test.tsx b/src/components/auth/AccountModal.test.tsx index e4d1b03c..a505a005 100644 --- a/src/components/auth/AccountModal.test.tsx +++ b/src/components/auth/AccountModal.test.tsx @@ -16,15 +16,18 @@ const baseUser: AuthUser = { id: 'user-1', username: 'tester', displayName: '138****8000', + avatarUrl: null, publicUserCode: 'user-tester', phoneNumberMasked: '138****8000', loginMethod: 'phone', bindingStatus: 'active', wechatBound: true, + createdAt: new Date().toISOString(), }; function renderAccountModal(overrides?: { user?: AuthUser; + entryMode?: 'settings' | 'account'; riskBlocks?: AuthRiskBlockSummary[]; sessions?: AuthSessionSummary[]; auditLogs?: AuthAuditLogEntry[]; @@ -40,6 +43,7 @@ function renderAccountModal(overrides?: { { expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull(); }); +test('direct account entry does not render the settings shell as another dialog', () => { + renderAccountModal({ entryMode: 'account' }); + + const accountDialog = screen.getByRole('dialog', { name: '账号信息' }); + expect(accountDialog).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '设置与账号安全' })).toBeNull(); + expect(screen.queryByText('设置与账号安全')).toBeNull(); + expect( + within(accountDialog).getByRole('button', { name: '关闭' }), + ).toBeTruthy(); + expect( + within(accountDialog).queryByRole('button', { name: '返回' }), + ).toBeNull(); +}); + test('account actions open in independent panels instead of inline expansion', async () => { const user = userEvent.setup(); @@ -130,9 +149,9 @@ test('nested settings panels keep back navigation without an extra close action' expect( within(accountDialog).getByRole('button', { name: '返回' }), ).toBeTruthy(); - expect( - accountHeader?.lastElementChild?.textContent?.includes('返回'), - ).toBe(true); + expect(accountHeader?.lastElementChild?.textContent?.includes('返回')).toBe( + true, + ); expect( within(accountDialog).queryByRole('button', { name: '关闭' }), ).toBeNull(); diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 12cd18ba..5864c53b 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -20,6 +20,7 @@ import { CaptchaChallengeField } from './CaptchaChallengeField'; type AccountModalProps = { user: AuthUser; isOpen: boolean; + entryMode?: 'settings' | 'account'; initialSection?: PlatformSettingsSection | null; platformTheme: PlatformTheme; riskBlocks: AuthRiskBlockSummary[]; @@ -159,6 +160,7 @@ function OverlayPanel({ title, description, action, + standalone = false, onBack, onClose, children, @@ -167,64 +169,73 @@ function OverlayPanel({ title: string; description?: string; action?: ReactNode; + standalone?: boolean; onBack?: () => void; onClose: () => void; children: ReactNode; }) { + const panel = ( +
event.stopPropagation()} + > +
+
+
+ {eyebrow} +
+
+ {title} +
+ {description ? ( +
+ {description} +
+ ) : null} +
+
+ {action} + {onBack ? ( + + ) : ( + + )} +
+
+ +
+ {children} +
+
+ ); + + if (standalone) { + return panel; + } + return (
-
event.stopPropagation()} - > -
-
-
- {eyebrow} -
-
- {title} -
- {description ? ( -
- {description} -
- ) : null} -
-
- {action} - {onBack ? ( - - ) : ( - - )} -
-
- -
- {children} -
-
+ {panel}
); } @@ -266,6 +277,7 @@ function ThemeOptionCard({ export function AccountModal({ user, isOpen, + entryMode = 'settings', initialSection = null, platformTheme, riskBlocks, @@ -314,6 +326,7 @@ export function AccountModal({ const sectionTriggerRef = useRef(null); const changePhoneTriggerRef = useRef(null); const passwordTriggerRef = useRef(null); + const isDirectAccountMode = entryMode === 'account'; const focusAfterNextPaint = useCallback((element: HTMLElement | null) => { if (!element) { @@ -347,7 +360,11 @@ export function AccountModal({ return; } - setActiveSection(normalizeSettingsSection(initialSection)); + setActiveSection( + isDirectAccountMode + ? 'account' + : normalizeSettingsSection(initialSection), + ); setIsChangePhonePanelOpen(false); setIsPasswordPanelOpen(false); setAccountNotice(''); @@ -356,7 +373,13 @@ export function AccountModal({ passwordTriggerRef.current = null; resetChangePhoneDraft(); resetPasswordDraft(); - }, [initialSection, isOpen, resetChangePhoneDraft, resetPasswordDraft]); + }, [ + initialSection, + isDirectAccountMode, + isOpen, + resetChangePhoneDraft, + resetPasswordDraft, + ]); useEffect(() => { const settingsHome = settingsHomeRef.current; @@ -446,47 +469,55 @@ export function AccountModal({ onClick={onClose} >
event.stopPropagation()} > -
-
-
- 设置与账号安全 + {!isDirectAccountMode ? ( +
+
+
+ 设置与账号安全 +
+
- -
+ ) : null} -
-
-
- {SETTINGS_SECTIONS.map((section) => ( - { - sectionTriggerRef.current = trigger; - setAccountNotice(''); - setActiveSection(section.id); - }} - /> - ))} + {!isDirectAccountMode ? ( +
+
+
+ {SETTINGS_SECTIONS.map((section) => ( + { + sectionTriggerRef.current = trigger; + setAccountNotice(''); + setActiveSection(section.id); + }} + /> + ))} +
-
+ ) : null} {activeSection === 'appearance' ? (
@@ -671,7 +703,10 @@ export function AccountModal({ {block.title} 剩余约{' '} - {Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '} + {Math.max( + 1, + Math.ceil(block.remainingSeconds / 60), + )}{' '} 分钟
@@ -965,7 +1000,9 @@ export function AccountModal({ type="password" autoComplete="current-password" placeholder="首次设置可留空" - onChange={(event) => setCurrentPassword(event.target.value)} + onChange={(event) => + setCurrentPassword(event.target.value) + } />
{children}
diff --git a/src/components/auth/AuthUiContext.ts b/src/components/auth/AuthUiContext.ts index 34ffba99..2bf5ac79 100644 --- a/src/components/auth/AuthUiContext.ts +++ b/src/components/auth/AuthUiContext.ts @@ -17,6 +17,7 @@ type AuthUiContextValue = { requireAuth: (action: () => void) => void; openSettingsModal: (section?: PlatformSettingsSection) => void; openAccountModal: () => void; + setCurrentUser: (user: AuthUser) => void; logout: () => Promise; musicVolume: number; setMusicVolume: (value: number) => void; diff --git a/src/components/auth/BindPhoneScreen.tsx b/src/components/auth/BindPhoneScreen.tsx index 4f44c1e8..0dce96f4 100644 --- a/src/components/auth/BindPhoneScreen.tsx +++ b/src/components/auth/BindPhoneScreen.tsx @@ -62,7 +62,7 @@ export function BindPhoneScreen({
-
叙世
+
百梦
视觉叙事 RPG

diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index 3c7577b8..4f859b72 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -196,9 +196,11 @@ export function LoginScreen({ /> ) : (

- {phoneLoginEnabled && passwordLoginEnabled ? ( + {phoneLoginEnabled ? (
@@ -208,12 +210,14 @@ export function LoginScreen({ > 短信登录 - setActiveLoginTab('password')} - > - 密码登录 - + {passwordLoginEnabled ? ( + setActiveLoginTab('password')} + > + 密码登录 + + ) : null}
) : null} diff --git a/src/components/auth/RegistrationInviteModal.tsx b/src/components/auth/RegistrationInviteModal.tsx new file mode 100644 index 00000000..a1c0c9e1 --- /dev/null +++ b/src/components/auth/RegistrationInviteModal.tsx @@ -0,0 +1,115 @@ +import { X } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; + +import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime'; + +type RegistrationInviteModalProps = { + isOpen: boolean; + platformTheme: PlatformTheme; + initialInviteCode: string; + submitting: boolean; + error: string; + onClose: () => void; + onSubmit: (inviteCode: string) => Promise; +}; + +export function RegistrationInviteModal({ + isOpen, + platformTheme, + initialInviteCode, + submitting, + error, + onClose, + onSubmit, +}: RegistrationInviteModalProps) { + const [inviteCode, setInviteCode] = useState(initialInviteCode); + const normalizedInviteCode = useMemo( + () => + inviteCode + .trim() + .replace(/[^0-9a-z]/gi, '') + .toUpperCase(), + [inviteCode], + ); + + useEffect(() => { + if (!isOpen) { + return; + } + + setInviteCode(initialInviteCode); + }, [initialInviteCode, isOpen]); + + if (!isOpen) { + return null; + } + + return ( +
+
event.stopPropagation()} + > +
+
+ 请填写邀请码 +
+ +
+
{ + event.preventDefault(); + if (!normalizedInviteCode) { + onClose(); + return; + } + + void onSubmit(normalizedInviteCode); + }} + > + + + {error ? ( +
+ {error} +
+ ) : null} + + +
+
+
+ ); +} diff --git a/src/components/creation-agent/CreationAgentWorkspace.tsx b/src/components/creation-agent/CreationAgentWorkspace.tsx index 0bb5ec86..45538f73 100644 --- a/src/components/creation-agent/CreationAgentWorkspace.tsx +++ b/src/components/creation-agent/CreationAgentWorkspace.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Paperclip, Send, Sparkles } from 'lucide-react'; +import { ArrowLeft, ImagePlus, Paperclip, Send, Sparkles, X } from 'lucide-react'; import type { ChangeEvent } from 'react'; import { useEffect, useRef, useState } from 'react'; @@ -75,15 +75,21 @@ type CreationAgentWorkspaceProps = { isBusy?: boolean; error?: string | null; quickActions?: CreationAgentQuickAction[]; + referenceImagePreviewSrc?: string | null; + referenceImageLabel?: string | null; + referenceImageError?: string | null; onBack: () => void; onSubmitText: (text: string, quickActionKey?: string) => void; onPrimaryAction: () => void; onQuickAction?: (action: CreationAgentQuickAction) => void; + onReferenceImageChange?: (file: File) => Promise | void; + onClearReferenceImage?: () => void; }; const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96; const DOCUMENT_INPUT_ACCEPT = '.txt,.md,.markdown,.csv,.json,text/plain,text/markdown,text/csv,application/json'; +const REFERENCE_IMAGE_INPUT_ACCEPT = 'image/png,image/jpeg,image/webp'; function uniqueRecommendedReplies(recommendedReplies: string[] = []) { return [ @@ -290,19 +296,26 @@ export function CreationAgentWorkspace({ isBusy = false, error = null, quickActions = [], + referenceImagePreviewSrc = null, + referenceImageLabel = null, + referenceImageError = null, onBack, onSubmitText, onPrimaryAction, onQuickAction, + onReferenceImageChange, + onClearReferenceImage, }: CreationAgentWorkspaceProps) { const [draftText, setDraftText] = useState(''); const [documentInputError, setDocumentInputError] = useState( null, ); const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false); + const [isReadingReferenceImage, setIsReadingReferenceImage] = useState(false); // 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。 const messageListRef = useRef(null); const documentInputRef = useRef(null); + const referenceImageInputRef = useRef(null); const shouldAutoScrollRef = useRef(true); useEffect(() => { @@ -376,7 +389,7 @@ export function CreationAgentWorkspace({ const submit = () => { const text = draftText.trim(); - if (!text || isBusy || isParsingDocumentInput) { + if (!text || isBusy || isParsingDocumentInput || isReadingReferenceImage) { return; } @@ -399,6 +412,10 @@ export function CreationAgentWorkspace({ documentInputRef.current?.click(); }; + const openReferenceImagePicker = () => { + referenceImageInputRef.current?.click(); + }; + const handleDocumentInputChange = async ( event: ChangeEvent, ) => { @@ -426,6 +443,25 @@ export function CreationAgentWorkspace({ } }; + const handleReferenceImageInputChange = async ( + event: ChangeEvent, + ) => { + const file = event.target.files?.[0] ?? null; + event.target.value = ''; + + if (!file || isBusy || isReadingReferenceImage || !onReferenceImageChange) { + return; + } + + setIsReadingReferenceImage(true); + + try { + await onReferenceImageChange(file); + } finally { + setIsReadingReferenceImage(false); + } + }; + return (
- {documentInputError || error ? ( + {referenceImagePreviewSrc ? ( +
+
+ 参考图 +
+
+ {referenceImageLabel || '已选择参考图'} +
+ {onClearReferenceImage ? ( + + ) : null} +
+ ) : null} + + {documentInputError || referenceImageError || error ? (
- {documentInputError || error} + {documentInputError || referenceImageError || error}
) : null} @@ -560,6 +623,15 @@ export function CreationAgentWorkspace({ className="hidden" onChange={handleDocumentInputChange} /> + {onReferenceImageChange ? ( + + ) : null} + {onReferenceImageChange ? ( + + ) : null}