master #14

Merged
kdletters merged 226 commits from master into release 2026-05-13 13:23:09 +08:00
513 changed files with 52813 additions and 6013 deletions
Showing only changes of commit 8f4ca9abfa - Show all commits

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

BIN
.codex-home-desktop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

BIN
.codex-home-mobile-wait.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
.codex-home-mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

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

12
apps/admin-web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>百梦后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

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

View File

@@ -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<string, string>;
signal?: AbortSignal;
}
export class AdminApiError extends Error {
status: number;
code: string;
details: Record<string, unknown> | null;
meta: ApiMeta | null;
responseText: string;
constructor(params: {
message: string;
status: number;
code?: string;
details?: Record<string, unknown> | 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<T>(
path: string,
options: AdminRequestOptions = {},
): Promise<T> {
const method = options.method ?? 'GET';
const headers: Record<string, string> = {
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<T>(payload);
}
export function loginAdmin(username: string, password: string) {
return request<AdminLoginResponse>('/admin/api/login', {
method: 'POST',
body: {username, password},
});
}
export function getAdminMe(token: string) {
return request<AdminMeResponse>('/admin/api/me', {token});
}
export function getAdminOverview(token: string) {
return request<AdminOverviewResponse>('/admin/api/overview', {token});
}
export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) {
return request<AdminDebugHttpResponse>('/admin/api/debug/http', {
method: 'POST',
token,
body: payload,
});
}
export function upsertProfileRedeemCode(
token: string,
payload: AdminUpsertProfileRedeemCodeRequest,
) {
return request<ProfileRedeemCodeAdminResponse>(
'/admin/api/profile/redeem-codes',
{
method: 'POST',
token,
body: payload,
},
);
}
export function upsertProfileInviteCode(
token: string,
payload: AdminUpsertProfileInviteCodeRequest,
) {
return request<ProfileInviteCodeAdminResponse>(
'/admin/api/profile/invite-codes',
{
method: 'POST',
token,
body: payload,
},
);
}
export function disableProfileRedeemCode(
token: string,
payload: AdminDisableProfileRedeemCodeRequest,
) {
return request<ProfileRedeemCodeAdminResponse>(
'/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<T>(payload: unknown): T {
if (isRecord(payload) && 'data' in payload) {
return (payload as ApiSuccessEnvelope<T>).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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View File

@@ -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<T> {
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<string, unknown>;
}
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<string, unknown>;
createdAt: string;
updatedAt: string;
}

View File

@@ -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<SessionStatus>('checking');
const [admin, setAdmin] = useState<AdminSessionPayload | null>(null);
const [token, setToken] = useState('');
const [routeId, setRouteId] = useState<AdminRouteId>(() =>
resolveAdminRoute(window.location.hash),
);
const [loginNotice, setLoginNotice] = useState('');
// 兑换码页会随页签切换卸载,最近操作记录需要放在会话层保留。
const [redeemResult, setRedeemResult] =
useState<ProfileRedeemCodeAdminResponse | null>(null);
const [inviteResult, setInviteResult] =
useState<ProfileInviteCodeAdminResponse | null>(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 (
<main className="admin-loading-screen">
<div className="admin-loading-mark" />
<span></span>
</main>
);
}
if (status === 'guest' || !admin || !token) {
return <AdminLoginPage notice={loginNotice} onLogin={handleLogin} />;
}
return (
<AdminShell
admin={admin}
routeId={routeId}
onLogout={handleLogout}
onRouteChange={handleRouteChange}
>
{routeId === 'overview' ? (
<AdminOverviewPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'debug' ? (
<AdminDebugHttpPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'redeem' ? (
<AdminRedeemCodePage
result={redeemResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setRedeemResult}
/>
) : null}
{routeId === 'invite' ? (
<AdminInviteCodePage
result={inviteResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setInviteResult}
/>
) : null}
</AdminShell>
);
}

View File

@@ -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<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
admin,
routeId,
children,
onRouteChange,
onLogout,
}: AdminShellProps) {
return (
<div className="admin-shell">
<aside className="admin-sidebar">
<div className="admin-brand">
<div className="admin-brand-icon">
<ShieldCheck size={20} aria-hidden="true" />
</div>
<div>
<strong></strong>
<span>Admin</span>
</div>
</div>
<nav className="admin-nav" aria-label="后台导航">
{adminRoutes.map((route) => {
const Icon = routeIcons[route.id];
return (
<button
className="admin-nav-button"
data-active={route.id === routeId}
key={route.id}
title={route.label}
type="button"
onClick={() => onRouteChange(route.id)}
>
<Icon size={18} aria-hidden="true" />
<span>{route.label}</span>
</button>
);
})}
</nav>
</aside>
<div className="admin-main">
<header className="admin-topbar">
<div className="admin-user">
<span>{admin.displayName || admin.username}</span>
<small>{admin.roles.join(' / ')}</small>
</div>
<button
className="admin-icon-button"
title="退出登录"
type="button"
onClick={onLogout}
>
<LogOut size={18} aria-hidden="true" />
<span>退</span>
</button>
</header>
<main className="admin-content">{children}</main>
</div>
<nav className="admin-bottom-nav" aria-label="后台导航">
{adminRoutes.map((route) => {
const Icon = routeIcons[route.id];
return (
<button
className="admin-bottom-nav-button"
data-active={route.id === routeId}
key={route.id}
title={route.label}
type="button"
onClick={() => onRouteChange(route.id)}
>
<Icon size={19} aria-hidden="true" />
<span>{route.label}</span>
</button>
);
})}
</nav>
</div>
);
}

View File

@@ -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'
);
}

View File

@@ -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'
);
}

View File

@@ -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(
<StrictMode>
<AdminApp />
</StrictMode>,
);

View File

@@ -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<AdminDebugHttpMethod>('GET');
const [path, setPath] = useState('/healthz');
const [body, setBody] = useState('');
const [headers, setHeaders] = useState<AdminDebugHeaderInput[]>([]);
const [result, setResult] = useState<AdminDebugHttpResponse | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const jsonPreview = useMemo(
() => formatUnknownJson(result?.bodyJson),
[result?.bodyJson],
);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
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 (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2>API </h2>
<p></p>
</div>
</div>
<div className="admin-two-column">
<form className="admin-panel admin-form" onSubmit={handleSubmit}>
<div className="admin-form-row">
<label className="admin-field admin-field-compact">
<span>Method</span>
<select
value={method}
onChange={(event) =>
setMethod(event.target.value as AdminDebugHttpMethod)
}
>
{httpMethods.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</label>
<label className="admin-field admin-field-fill">
<span>Path</span>
<input
value={path}
onChange={(event) => setPath(event.target.value)}
/>
</label>
</div>
<section className="admin-subsection">
<div className="admin-subsection-heading">
<span>Headers</span>
<button
className="admin-ghost-button"
type="button"
onClick={() =>
setHeaders((current) => [
...current,
{name: '', value: ''},
])
}
>
<Plus size={16} aria-hidden="true" />
</button>
</div>
<div className="admin-header-editor">
{headers.map((header, index) => (
<div className="admin-header-row" key={index}>
<input
value={header.name}
onChange={(event) =>
setHeaders((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? {...item, name: event.target.value}
: item,
),
)
}
/>
<input
value={header.value}
onChange={(event) =>
setHeaders((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? {...item, value: event.target.value}
: item,
),
)
}
/>
<button
className="admin-ghost-button"
title="移除"
type="button"
onClick={() =>
setHeaders((current) =>
current.filter((_, itemIndex) => itemIndex !== index),
)
}
>
<Trash2 size={16} aria-hidden="true" />
</button>
</div>
))}
</div>
</section>
<label className="admin-field">
<span>Body</span>
<textarea
rows={9}
value={body}
onChange={(event) => setBody(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={isSubmitting || !path.trim().startsWith('/')}
type="submit"
>
<Send size={17} aria-hidden="true" />
<span>{isSubmitting ? '发送中' : '发送'}</span>
</button>
</form>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result ? `${result.status} ${result.statusText}` : '-'}</span>
</div>
{result ? (
<>
<dl className="admin-info-list">
<div>
<dt>Status</dt>
<dd>{result.status}</dd>
</div>
<div>
<dt>Headers</dt>
<dd>{result.headers.length}</dd>
</div>
</dl>
<pre className="admin-code-block">
{jsonPreview || result.bodyText || '(empty)'}
</pre>
</>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</div>
</section>
);
}

View File

@@ -0,0 +1,158 @@
import {Save} from 'lucide-react';
import {FormEvent, useState} from 'react';
import {upsertProfileInviteCode} from '../api/adminApiClient';
import type {ProfileInviteCodeAdminResponse} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
interface AdminInviteCodePageProps {
token: string;
result: ProfileInviteCodeAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileInviteCodeAdminResponse) => void;
}
export function AdminInviteCodePage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminInviteCodePageProps) {
const [inviteCode, setInviteCode] = useState('');
const [metadataText, setMetadataText] = useState('{}');
const [errorMessage, setErrorMessage] = useState('');
const [isSaving, setIsSaving] = useState(false);
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
setIsSaving(true);
try {
const response = await upsertProfileInviteCode(token, {
inviteCode: inviteCode.trim(),
metadata: parseMetadata(metadataText),
});
onResultChange(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
</div>
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<label className="admin-field">
<span>Invite Code</span>
<input
autoComplete="off"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
/>
</label>
<label className="admin-field">
<span>Metadata JSON</span>
<textarea
rows={10}
spellCheck={false}
value={metadataText}
onChange={(event) => setMetadataText(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={isSaving || !inviteCode.trim() || !isMetadataReady(metadataText)}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result?.inviteCode ?? '-'}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt>User ID</dt>
<dd>{result.userId}</dd>
</div>
<div>
<dt></dt>
<dd>{result.inviteCode}</dd>
</div>
<div>
<dt></dt>
<dd>{result.createdAt}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedAt}</dd>
</div>
<div>
<dt>Metadata</dt>
<dd>
<pre className="admin-code-block">
{JSON.stringify(result.metadata, null, 2)}
</pre>
</dd>
</div>
</dl>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</div>
</section>
);
}
function parseMetadata(value: string): Record<string, unknown> {
const trimmed = value.trim();
if (!trimmed) {
return {};
}
const parsed = JSON.parse(trimmed) as unknown;
if (!isRecord(parsed)) {
throw new Error('Metadata 必须是 JSON 对象');
}
return parsed;
}
function isMetadataReady(value: string) {
try {
parseMetadata(value);
return true;
} catch {
return false;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View File

@@ -0,0 +1,83 @@
import {LockKeyhole, ShieldCheck} from 'lucide-react';
import {FormEvent, useState} from 'react';
import {formatAdminApiError} from '../api/adminApiClient';
interface AdminLoginPageProps {
notice: string;
onLogin: (username: string, password: string) => Promise<void>;
}
export function AdminLoginPage({notice, onLogin}: AdminLoginPageProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSubmitting) {
return;
}
setErrorMessage('');
setIsSubmitting(true);
try {
await onLogin(username.trim(), password);
} catch (error: unknown) {
setErrorMessage(formatAdminApiError(error));
} finally {
setIsSubmitting(false);
}
}
return (
<main className="admin-login-screen">
<form className="admin-login-panel" onSubmit={handleSubmit}>
<div className="admin-login-brand">
<div className="admin-brand-icon admin-brand-icon-large">
<ShieldCheck size={26} aria-hidden="true" />
</div>
<div>
<h1></h1>
<span>Admin Console</span>
</div>
</div>
<label className="admin-field">
<span></span>
<input
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{notice || errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage || notice}
</div>
) : null}
<button
className="admin-primary-button"
disabled={isSubmitting || !username.trim() || !password}
type="submit"
>
<LockKeyhole size={18} aria-hidden="true" />
<span>{isSubmitting ? '登录中' : '登录'}</span>
</button>
</form>
</main>
);
}

View File

@@ -0,0 +1,171 @@
import {RefreshCw} from 'lucide-react';
import {useCallback, useEffect, useState} from 'react';
import {getAdminOverview} from '../api/adminApiClient';
import type {
AdminDatabaseTableStatPayload,
AdminOverviewResponse,
} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
interface AdminOverviewPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
export function AdminOverviewPage({
token,
onUnauthorized,
}: AdminOverviewPageProps) {
const [overview, setOverview] = useState<AdminOverviewResponse | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const loadOverview = useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await getAdminOverview(token);
setOverview(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [onUnauthorized, token]);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={() => void loadOverview()}
>
<RefreshCw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<div className="admin-overview-grid">
<InfoPanel
title="服务"
rows={[
['监听', overview ? `${overview.service.bindHost}:${overview.service.bindPort}` : '-'],
['JWT issuer', overview?.service.jwtIssuer ?? '-'],
['后台', overview?.service.adminEnabled ? '已启用' : '未启用'],
]}
/>
<InfoPanel
title="SpacetimeDB"
rows={[
['Server', overview?.service.spacetimeServerUrl ?? '-'],
['Database', overview?.service.spacetimeDatabase ?? '-'],
['Identity', overview?.database.databaseIdentity ?? '-'],
['Owner', overview?.database.ownerIdentity ?? '-'],
['Host', overview?.database.hostType ?? '-'],
]}
/>
</div>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{overview?.database.schemaTableNames.length ?? 0} tables</span>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{(overview?.database.tableStats ?? []).map((stat) => (
<TableStatRow key={stat.tableName} stat={stat} />
))}
{overview && overview.database.tableStats.length === 0 ? (
<tr>
<td colSpan={3}></td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
{overview?.database.fetchErrors.length ? (
<section className="admin-panel admin-panel-warning">
<div className="admin-panel-heading">
<h3></h3>
<span>{overview.database.fetchErrors.length}</span>
</div>
<ul className="admin-error-list">
{overview.database.fetchErrors.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
</section>
) : null}
</section>
);
}
function InfoPanel({
title,
rows,
}: {
title: string;
rows: Array<[string, string]>;
}) {
return (
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>{title}</h3>
</div>
<dl className="admin-info-list">
{rows.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</section>
);
}
function TableStatRow({stat}: {stat: AdminDatabaseTableStatPayload}) {
return (
<tr>
<td>{stat.tableName}</td>
<td>{typeof stat.rowCount === 'number' ? stat.rowCount : '-'}</td>
<td>
{stat.errorMessage ? (
<span className="admin-status admin-status-error">
{stat.errorMessage}
</span>
) : (
<span className="admin-status admin-status-ok">OK</span>
)}
</td>
</tr>
);
}

View File

@@ -0,0 +1,275 @@
import {PowerOff, Save} from 'lucide-react';
import {FormEvent, useState} from 'react';
import {
disableProfileRedeemCode,
upsertProfileRedeemCode,
} from '../api/adminApiClient';
import type {
ProfileRedeemCodeAdminResponse,
ProfileRedeemCodeMode,
} from '../api/adminApiTypes';
import {handlePageError, splitLines} from './pageUtils';
interface AdminRedeemCodePageProps {
token: string;
result: ProfileRedeemCodeAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileRedeemCodeAdminResponse) => void;
}
const redeemModes: Array<{value: ProfileRedeemCodeMode; label: string}> = [
{value: 'public', label: '公共码'},
{value: 'unique', label: '唯一码'},
{value: 'private', label: '私有码'},
];
export function AdminRedeemCodePage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminRedeemCodePageProps) {
const [code, setCode] = useState('');
const [mode, setMode] = useState<ProfileRedeemCodeMode>('public');
const [rewardPoints, setRewardPoints] = useState('100');
const [maxUses, setMaxUses] = useState('1');
const [enabled, setEnabled] = useState(true);
const [allowedUserIds, setAllowedUserIds] = useState('');
const [allowedPublicUserCodes, setAllowedPublicUserCodes] = useState('');
const [disableCode, setDisableCode] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [disableErrorMessage, setDisableErrorMessage] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
setIsSaving(true);
try {
const response = await upsertProfileRedeemCode(token, {
code: code.trim(),
mode,
rewardPoints: parsePositiveInteger(rewardPoints),
maxUses: parsePositiveInteger(maxUses),
enabled,
allowedUserIds: mode === 'private' ? splitLines(allowedUserIds) : [],
allowedPublicUserCodes:
mode === 'private' ? splitLines(allowedPublicUserCodes) : [],
});
onResultChange(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
async function handleDisable(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isDisabling) {
return;
}
setDisableErrorMessage('');
setIsDisabling(true);
try {
const response = await disableProfileRedeemCode(token, {
code: disableCode.trim(),
});
onResultChange(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setDisableErrorMessage);
} finally {
setIsDisabling(false);
}
}
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
</div>
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-form-row">
<label className="admin-field admin-field-fill">
<span>Code</span>
<input
value={code}
onChange={(event) => setCode(event.target.value)}
/>
</label>
<label className="admin-switch-field">
<input
checked={enabled}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span></span>
</label>
</div>
<div className="admin-segmented-control" role="tablist">
{redeemModes.map((item) => (
<button
data-active={mode === item.value}
key={item.value}
type="button"
onClick={() => setMode(item.value)}
>
{item.label}
</button>
))}
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={rewardPoints}
onChange={(event) => setRewardPoints(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={maxUses}
onChange={(event) => setMaxUses(event.target.value)}
/>
</label>
</div>
{mode === 'private' ? (
<div className="admin-form-row">
<label className="admin-field">
<span> userId</span>
<textarea
rows={6}
value={allowedUserIds}
onChange={(event) => setAllowedUserIds(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<textarea
rows={6}
value={allowedPublicUserCodes}
onChange={(event) =>
setAllowedPublicUserCodes(event.target.value)
}
/>
</label>
</div>
) : null}
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={
isSaving ||
!code.trim() ||
!parsePositiveInteger(rewardPoints) ||
!parsePositiveInteger(maxUses)
}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
<div className="admin-stack">
<form className="admin-panel admin-form" onSubmit={handleDisable}>
<label className="admin-field">
<span> Code</span>
<input
value={disableCode}
onChange={(event) => setDisableCode(event.target.value)}
/>
</label>
{disableErrorMessage ? (
<div className="admin-alert" role="status">
{disableErrorMessage}
</div>
) : null}
<button
className="admin-danger-button"
disabled={isDisabling || !disableCode.trim()}
type="submit"
>
<PowerOff size={17} aria-hidden="true" />
<span>{isDisabling ? '停用中' : '停用'}</span>
</button>
</form>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result?.mode ?? '-'}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt>Code</dt>
<dd>{result.code}</dd>
</div>
<div>
<dt></dt>
<dd>{result.rewardPoints}</dd>
</div>
<div>
<dt></dt>
<dd>{result.maxUses}</dd>
</div>
<div>
<dt></dt>
<dd>{result.globalUsedCount}</dd>
</div>
<div>
<dt></dt>
<dd>{result.enabled ? '启用' : '停用'}</dd>
</div>
<div>
<dt></dt>
<dd>{result.createdBy}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedAt}</dd>
</div>
</dl>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</div>
</div>
</section>
);
}
function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}

View File

@@ -0,0 +1,33 @@
import {formatAdminApiError, isAdminApiError} from '../api/adminApiClient';
export function handlePageError(
error: unknown,
onUnauthorized: (message?: string) => void,
setError: (message: string) => void,
) {
if (isAdminApiError(error) && error.status === 401) {
onUnauthorized('登录状态已失效');
return;
}
setError(formatAdminApiError(error));
}
export function splitLines(value: string) {
return value
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
}
export function formatUnknownJson(value: unknown) {
if (value === null || typeof value === 'undefined') {
return '';
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}

View File

@@ -0,0 +1,661 @@
:root {
color: #17212b;
background: #eef3f6;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background: #eef3f6;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
#root {
min-height: 100vh;
}
.admin-loading-screen,
.admin-login-screen {
display: grid;
min-height: 100dvh;
place-items: center;
padding: 20px;
}
.admin-loading-screen {
gap: 12px;
color: #5c6b77;
}
.admin-loading-mark {
width: 24px;
height: 24px;
border: 3px solid #d1dde6;
border-top-color: #126e82;
border-radius: 50%;
animation: admin-spin 0.8s linear infinite;
}
.admin-login-screen {
background:
linear-gradient(145deg, rgba(18, 110, 130, 0.12), transparent 36%),
linear-gradient(315deg, rgba(165, 94, 54, 0.12), transparent 34%),
#eef3f6;
}
.admin-login-panel,
.admin-panel {
border: 1px solid #d8e2e8;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 36px rgba(23, 33, 43, 0.08);
}
.admin-login-panel {
display: grid;
width: min(100%, 420px);
gap: 18px;
padding: 28px;
}
.admin-login-brand,
.admin-brand {
display: flex;
align-items: center;
gap: 12px;
}
.admin-login-brand h1 {
margin: 0;
color: #17212b;
font-size: 26px;
line-height: 1.16;
}
.admin-login-brand span,
.admin-brand span {
color: #667682;
font-size: 12px;
}
.admin-brand-icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border: 1px solid #bcd2db;
border-radius: 8px;
color: #126e82;
background: #e7f3f5;
}
.admin-brand-icon-large {
width: 48px;
height: 48px;
}
.admin-shell {
display: grid;
min-height: 100dvh;
grid-template-columns: 232px minmax(0, 1fr);
}
.admin-sidebar {
display: flex;
flex-direction: column;
gap: 24px;
border-right: 1px solid #d8e2e8;
background: #ffffff;
padding: 22px 18px;
}
.admin-brand strong {
display: block;
font-size: 17px;
}
.admin-nav {
display: grid;
gap: 8px;
}
.admin-nav-button,
.admin-bottom-nav-button,
.admin-icon-button,
.admin-primary-button,
.admin-secondary-button,
.admin-danger-button,
.admin-ghost-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
white-space: nowrap;
}
.admin-nav-button {
justify-content: flex-start;
gap: 10px;
min-height: 42px;
padding: 0 12px;
color: #52616d;
background: transparent;
}
.admin-nav-button[data-active="true"] {
color: #0f5666;
background: #e7f3f5;
}
.admin-main {
display: grid;
min-width: 0;
grid-template-rows: 64px minmax(0, 1fr);
}
.admin-topbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
border-bottom: 1px solid #d8e2e8;
background: rgba(255, 255, 255, 0.86);
padding: 0 24px;
}
.admin-user {
display: grid;
justify-items: end;
gap: 2px;
}
.admin-user span {
font-weight: 650;
}
.admin-user small {
color: #667682;
}
.admin-content {
min-width: 0;
padding: 24px;
overflow: auto;
}
.admin-page {
display: grid;
gap: 18px;
max-width: 1180px;
margin: 0 auto;
}
.admin-page-heading,
.admin-panel-heading,
.admin-subsection-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.admin-page-heading h2,
.admin-panel-heading h3 {
margin: 0;
color: #17212b;
}
.admin-page-heading h2 {
font-size: 25px;
}
.admin-page-heading p {
margin: 3px 0 0;
color: #667682;
}
.admin-panel {
display: grid;
gap: 16px;
min-width: 0;
padding: 18px;
}
.admin-panel-heading h3 {
font-size: 16px;
}
.admin-panel-heading span {
color: #667682;
font-size: 13px;
}
.admin-panel-warning {
border-color: #efc894;
background: #fffaf3;
}
.admin-overview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.admin-two-column {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1.1fr);
gap: 16px;
align-items: start;
}
.admin-two-column-wide {
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
}
.admin-stack,
.admin-form {
display: grid;
gap: 16px;
}
.admin-form-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: end;
}
.admin-field {
display: grid;
min-width: 0;
gap: 7px;
color: #4c5c68;
font-size: 13px;
font-weight: 650;
}
.admin-field-fill {
min-width: 0;
}
.admin-field-compact {
max-width: 160px;
}
.admin-field input,
.admin-field select,
.admin-field textarea {
width: 100%;
min-height: 42px;
border: 1px solid #cbd8e0;
border-radius: 8px;
color: #17212b;
background: #fbfdfe;
padding: 9px 11px;
outline: none;
}
.admin-field textarea {
min-height: 112px;
resize: vertical;
}
.admin-field input:focus,
.admin-field select:focus,
.admin-field textarea:focus {
border-color: #126e82;
box-shadow: 0 0 0 3px rgba(18, 110, 130, 0.16);
}
.admin-switch-field {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-height: 42px;
color: #4c5c68;
font-size: 13px;
font-weight: 650;
}
.admin-switch-field input {
width: 18px;
height: 18px;
accent-color: #126e82;
}
.admin-primary-button,
.admin-secondary-button,
.admin-danger-button,
.admin-icon-button {
gap: 8px;
min-height: 40px;
padding: 0 14px;
font-weight: 700;
}
.admin-primary-button {
color: #ffffff;
background: #126e82;
}
.admin-secondary-button,
.admin-icon-button {
border: 1px solid #cbd8e0;
color: #2f4550;
background: #ffffff;
}
.admin-danger-button {
color: #ffffff;
background: #a44242;
}
.admin-ghost-button {
width: 34px;
height: 34px;
color: #52616d;
background: #eef3f6;
}
.admin-alert {
border: 1px solid #efc0bd;
border-radius: 8px;
color: #8a2f2f;
background: #fff4f3;
padding: 10px 12px;
font-size: 13px;
}
.admin-info-list {
display: grid;
gap: 10px;
margin: 0;
}
.admin-info-list div {
display: grid;
grid-template-columns: minmax(90px, 0.34fr) minmax(0, 1fr);
gap: 12px;
align-items: baseline;
}
.admin-info-list dt {
color: #667682;
font-size: 12px;
}
.admin-info-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #17212b;
font-size: 13px;
font-weight: 650;
}
.admin-table-wrap {
overflow: auto;
}
.admin-table {
width: 100%;
min-width: 620px;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
border-bottom: 1px solid #e4edf2;
padding: 10px;
text-align: left;
vertical-align: top;
}
.admin-table th {
color: #667682;
font-size: 12px;
}
.admin-status {
display: inline-flex;
max-width: 460px;
border-radius: 999px;
padding: 3px 8px;
font-size: 12px;
overflow-wrap: anywhere;
}
.admin-status-ok {
color: #17623c;
background: #e6f5ed;
}
.admin-status-error {
color: #8a2f2f;
background: #fff1ef;
}
.admin-error-list {
display: grid;
gap: 8px;
margin: 0;
padding-left: 18px;
color: #8a5a1b;
overflow-wrap: anywhere;
}
.admin-subsection {
display: grid;
gap: 10px;
}
.admin-subsection-heading {
color: #4c5c68;
font-size: 13px;
font-weight: 650;
}
.admin-header-editor {
display: grid;
gap: 8px;
}
.admin-header-row {
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1fr) 34px;
gap: 8px;
align-items: center;
}
.admin-header-row input {
min-width: 0;
min-height: 38px;
border: 1px solid #cbd8e0;
border-radius: 8px;
padding: 8px 10px;
}
.admin-result-panel {
min-height: 260px;
}
.admin-code-block {
max-height: 520px;
margin: 0;
overflow: auto;
border: 1px solid #dce6ec;
border-radius: 8px;
background: #17212b;
color: #e9f2f4;
padding: 14px;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.admin-empty-state {
display: grid;
min-height: 140px;
place-items: center;
border: 1px dashed #cbd8e0;
border-radius: 8px;
color: #667682;
background: #fbfdfe;
}
.admin-segmented-control {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
border: 1px solid #d8e2e8;
border-radius: 8px;
background: #eef3f6;
padding: 4px;
}
.admin-segmented-control button {
min-height: 36px;
border: 0;
border-radius: 6px;
color: #52616d;
background: transparent;
font-weight: 700;
}
.admin-segmented-control button[data-active="true"] {
color: #0f5666;
background: #ffffff;
box-shadow: 0 2px 8px rgba(23, 33, 43, 0.08);
}
.admin-bottom-nav {
display: none;
}
@keyframes admin-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 980px) {
.admin-shell {
grid-template-columns: 1fr;
}
.admin-sidebar {
display: none;
}
.admin-main {
grid-template-rows: 58px minmax(0, 1fr);
}
.admin-topbar {
justify-content: space-between;
padding: 0 14px;
}
.admin-content {
padding: 16px 14px 86px;
}
.admin-overview-grid,
.admin-two-column,
.admin-two-column-wide,
.admin-form-row {
grid-template-columns: 1fr;
}
.admin-field-compact {
max-width: none;
}
.admin-bottom-nav {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-top: 1px solid #d8e2e8;
background: rgba(255, 255, 255, 0.94);
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
backdrop-filter: blur(10px);
}
.admin-bottom-nav-button {
display: grid;
gap: 4px;
min-height: 48px;
color: #667682;
background: transparent;
font-size: 11px;
font-weight: 700;
}
.admin-bottom-nav-button[data-active="true"] {
color: #0f5666;
background: #e7f3f5;
}
}
@media (max-width: 560px) {
.admin-login-panel,
.admin-panel {
padding: 16px;
}
.admin-login-brand h1,
.admin-page-heading h2 {
font-size: 22px;
}
.admin-info-list div {
grid-template-columns: 1fr;
gap: 3px;
}
.admin-header-row {
grid-template-columns: 1fr;
}
.admin-header-row .admin-ghost-button {
width: 100%;
}
.admin-icon-button span {
display: none;
}
}

9
apps/admin-web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ADMIN_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": false,
"strict": true,
"noUncheckedIndexedAccess": true,
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

View File

@@ -0,0 +1,43 @@
import {dirname, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import react from '@vitejs/plugin-react';
import {defineConfig, loadEnv} from 'vite';
const adminWebRoot = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(adminWebRoot, '../..');
export default defineConfig(({mode}) => {
const repoEnv = loadEnv(mode, repoRoot, '');
const appEnv = loadEnv(mode, adminWebRoot, '');
const env = {...repoEnv, ...appEnv};
const apiTarget =
env.ADMIN_API_TARGET ||
env.GENARRATIVE_API_TARGET ||
`http://127.0.0.1:${env.GENARRATIVE_API_PORT || '3100'}`;
return {
root: adminWebRoot,
envDir: repoRoot,
plugins: [react()],
server: {
proxy: {
'/admin/api': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
'/healthz': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
chunkSizeWarningLimit: 600,
},
};
});

View File

@@ -11,7 +11,9 @@
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。
- [参考目录](./reference/README.md):脚本/Function 速查入口。
重点补充RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
- [PRD](./prd):产品需求与阶段计划;新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md)。
- [PRD](./prd):产品需求与阶段计划;后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md)新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md)
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和 Jenkins 数据库迁移流水线见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。

View File

@@ -6,7 +6,7 @@
当前自定义世界创作工具已经有了比较强的生成骨架、锚点结构和结果编辑能力,但整体仍处在一个很明显的“半收口状态”:
**设计目标已经走到“创作者工作台”,数据结构已经支持“锚点化输入”,但实际体验仍然更像“大文本生成器 + 大型结果总表编辑器”。**
**设计目标已经走到“百梦主工作台”,数据结构已经支持“锚点化输入”,但实际体验仍然更像“大文本生成器 + 大型结果总表编辑器”。**
如果用一句话概括当前问题,就是:
@@ -61,7 +61,7 @@
- 标志性要素
- 禁止事项
但实际入口 `src/components/SelectionCustomizationModals.tsx` 里,创作者弹窗仍然基本只有:
但实际入口 `src/components/SelectionCustomizationModals.tsx` 里,百梦主弹窗仍然基本只有:
- 生成模式
- 一块大 textarea
@@ -82,7 +82,7 @@
---
## 2.2 澄清机制已经存在,但没有真正服务创作者
## 2.2 澄清机制已经存在,但没有真正服务百梦主
`server-node/src/services/customWorldSessionStore.ts` 已经支持:
@@ -101,7 +101,7 @@
这意味着:
**系统表面上已经有“先澄清再生成”的能力,但实际体验里,创作者并没有真正参与这一步。**
**系统表面上已经有“先澄清再生成”的能力,但实际体验里,百梦主并没有真正参与这一步。**
结果就是:
@@ -113,7 +113,7 @@
- 把 session question 真正接到前端,作为生成前的二次确认步骤。
- 每次只问 `1~3` 个最关键问题,不要把它做成问卷。
- 支持“一键使用系统建议”,但必须让创作者可见,而不是静默自动填充。
- 支持“一键使用系统建议”,但必须让百梦主可见,而不是静默自动填充。
- 把回答结果回写到 `creatorIntent`,而不是只作为一次性会话答案。
---
@@ -175,7 +175,7 @@
这会带来三层问题:
1. 创作者负担过重
1. 百梦主负担过重
- 很多字段属于“系统编译层”,不属于“创作决策层”。
2. 移动端负担过重
@@ -240,7 +240,7 @@
---
## 2.6 快速模式还不够“快”,生成页也还不够“创作者视角”
## 2.6 快速模式还不够“快”,生成页也还不够“百梦主视角”
当前快速模式的主要区别,是把数量降成:
@@ -269,7 +269,7 @@
- 计时
- 模型阶段
而不是创作者真正关心的:
而不是百梦主真正关心的:
- 关键角色有没有成型
- 核心冲突有没有稳定
@@ -281,7 +281,7 @@
- 快速模式改成真正的“关键锚点预览模式”:
- 先只生成关键角色、关键地点、核心冲突摘要
- 暂不补全所有长尾档案
- 生成页改成“创作者视角进度”:
- 生成页改成“百梦主视角进度”:
- 世界灵魂已确定
- 关键角色已成型
- 关键地点已落地
@@ -391,16 +391,16 @@
### P0先修主链路闭环
- 补卡片化输入入口,至少把关键锚点输入真正开放出来。
- 把澄清问题正式接入创作者流程,不再静默自动兜底。
- 把澄清问题正式接入百梦主流程,不再静默自动兜底。
- 修正“新建完成后直接回世界列表”的流程,生成后默认进入结果工作台。
- 统一锁定与局部重生成规则,先让“创作者不怕重生成”成立。
- 统一锁定与局部重生成规则,先让“百梦主不怕重生成”成立。
### P1再降低工作台负担
- 结果页默认只展示高杠杆编辑。
- 低杠杆字段进入高级模式。
- 快速模式改成真正的关键对象预览模式。
- 生成页改成创作者视角进度,而不是模型批次视角。
- 生成页改成百梦主视角进度,而不是模型批次视角。
### P2最后做架构收口与去模板化
@@ -441,4 +441,4 @@
当前自定义世界创作工具最需要的,不是再继续补更多字段或更多生成步骤,而是:
**把“创作者先决定灵魂锚点,系统再稳定展开世界”这条主逻辑真正落到 UI、流程和后端边界上。**
**把“百梦主先决定灵魂锚点,系统再稳定展开世界”这条主逻辑真正落到 UI、流程和后端边界上。**

View File

@@ -1,4 +1,4 @@
# 自定义世界创作者输入与 AI 分工边界设计
# 自定义世界百梦主输入与 AI 分工边界设计
更新时间:`2026-04-06`
@@ -6,9 +6,9 @@
这份文档回答一个非常关键的问题:
**在“低创作门槛、高创作自由度”的前提下,自定义世界里哪些内容应该交给创作者直接定义,哪些内容应该交给 AI 和系统完成。**
**在“低创作门槛、高创作自由度”的前提下,自定义世界里哪些内容应该交给百梦主直接定义,哪些内容应该交给 AI 和系统完成。**
这里默认我们的创作者
这里默认我们的百梦主
- 不需要有专业作家背景
- 不需要有专业游戏设计背景
@@ -16,33 +16,33 @@
一句话目标:
**让创作者把精力放在“决定这个世界为什么值得被创作”,把 AI 用在“把这个世界展开、编译、铺开、校验、补足”。**
**让百梦主把精力放在“决定这个世界为什么值得被创作”,把 AI 用在“把这个世界展开、编译、铺开、校验、补足”。**
## 1. 总体结论
自定义世界的分工边界应该遵守 3 条硬原则:
1. 灵魂归创作者,杂活归 AI。
- 凡是决定作品气质、主题、冲突、人物关系、审美方向的内容,都应由创作者掌握。
1. 灵魂归百梦主,杂活归 AI。
- 凡是决定作品气质、主题、冲突、人物关系、审美方向的内容,都应由百梦主掌握。
2. 重点对象归创作者,长尾铺量归 AI。
- 创作者应重点塑造少量关键角色、关键地点、关键冲突、关键意象,而不是被迫手填几十个 NPC、几十个场景、几百条描述。
2. 重点对象归百梦主,长尾铺量归 AI。
- 百梦主应重点塑造少量关键角色、关键地点、关键冲突、关键意象,而不是被迫手填几十个 NPC、几十个场景、几百条描述。
3. 决策归创作者,编译归 AI / 系统。
- 创作者负责说“这个世界要成为什么样”AI / 系统负责把它编译成可运行的数据、规则、文本、关系钩子和运行时结构。
3. 决策归百梦主,编译归 AI / 系统。
- 百梦主负责说“这个世界要成为什么样”AI / 系统负责把它编译成可运行的数据、规则、文本、关系钩子和运行时结构。
这意味着:
- 创作者应该主要编辑“高杠杆创作锚点”
- 百梦主应该主要编辑“高杠杆创作锚点”
- AI 应该主要承担“批量展开 + 结构编译 + 一致性维护 + 专业执行”
## 2. 什么内容应该交给创作者
## 2. 什么内容应该交给百梦主
真正应该交给创作者的,不是大量表格字段,而是下面这些会显著决定作品质量、且 AI 不擅长替代的内容。
真正应该交给百梦主的,不是大量表格字段,而是下面这些会显著决定作品质量、且 AI 不擅长替代的内容。
## 2.1 世界核心命题
创作者应该直接定义:
百梦主应该直接定义:
- 这个世界的一句话设定
- 这个世界最吸引人的核心幻想
@@ -56,7 +56,7 @@
## 2.2 主题、气质与边界
创作者应该直接定义:
百梦主应该直接定义:
- 主题关键词
- 情绪基调
@@ -71,7 +71,7 @@
## 2.3 玩家身份与开局处境
创作者应该直接定义:
百梦主应该直接定义:
- 玩家扮演的是什么人
- 玩家一开始最缺什么、最想要什么
@@ -85,7 +85,7 @@
## 2.4 核心冲突与关键势力
创作者应该直接定义少量高价值内容:
百梦主应该直接定义少量高价值内容:
- 世界当前最重要的 `2~4` 条明面冲突
- 世界背后最关键的 `1~3` 条暗面问题
@@ -96,13 +96,13 @@
- 冲突结构决定世界是否“有戏”
- 势力关系是 AI 最容易写散、写平、写成百科介绍的部分
- 这一层由创作者把握,才能真正提高作品的辨识度
- 这一层由百梦主把握,才能真正提高作品的辨识度
## 2.5 关键角色与关系张力
创作者应该直接定义少量关键角色,而不是所有 NPC。
百梦主应该直接定义少量关键角色,而不是所有 NPC。
建议重点交给创作者的,是:
建议重点交给百梦主的,是:
- `3~8` 个关键角色
- 玩家与这些人的潜在关系
@@ -113,11 +113,11 @@
- 角色关系是最能显著提升作品质量的部分之一
- 这也是 AI 最容易写得“完整但无味”的部分
- 创作者不需要写长篇背景,但应掌握这些角色真正的关系骨架
- 百梦主不需要写长篇背景,但应掌握这些角色真正的关系骨架
## 2.6 关键地点与空间记忆点
创作者应该直接定义:
百梦主应该直接定义:
- `4~12` 个关键地点 / 区域 / 地标
- 这些地方为什么重要
@@ -131,7 +131,7 @@
## 2.7 标志性意象、物件、怪物、制度与规则
创作者应该优先控制世界里最能代表它的东西:
百梦主应该优先控制世界里最能代表它的东西:
- 标志性物件
- 标志性怪物 / 生物
@@ -144,9 +144,9 @@
- 这些内容决定世界的“手感”
- 它们不是普通细节,而是会反复影响命名、剧情、视觉、对话与玩法解释的母题
## 2.8 创作者应直接控制的“禁止事项”
## 2.8 百梦主应直接控制的“禁止事项”
创作者必须能明确锁定:
百梦主必须能明确锁定:
- 什么绝对不能改
- 什么不能被 AI 自动扩写到别的方向
@@ -156,7 +156,7 @@
原因:
- 高自由度不等于所有内容都开放漂移
- 如果没有“锁定机制”AI 会把创作者真正关心的内容稀释掉
- 如果没有“锁定机制”AI 会把百梦主真正关心的内容稀释掉
## 3. 什么内容应该交给 AI 和系统
@@ -176,7 +176,7 @@
原因:
- 这些内容数量大、重复度高
- 它们需要“贴合世界”,但不需要都由创作者逐个手写
- 它们需要“贴合世界”,但不需要都由百梦主逐个手写
- AI 很适合做“围绕锚点的批量铺量”
## 3.2 从创作锚点到系统结构的编译
@@ -186,7 +186,7 @@
- 从自然语言世界设定中提取题材词汇
- 从关键冲突中编译出世界叙事图谱
- 从关键角色卡编译出角色叙事档案
-创作者输入里自动生成标签、钩子、隐藏线索、章节摘要
-百梦主输入里自动生成标签、钩子、隐藏线索、章节摘要
- 从地点和关系中编译出场景连接、事件触发和叙事回响
对应当前仓库,下面这些结构更适合由 AI / 系统生成,而不是让玩家直接编辑:
@@ -203,7 +203,7 @@
原因:
- 这些是运行时结构,不是创作者真正想表达的作品内容
- 这些是运行时结构,不是百梦主真正想表达的作品内容
- 直接暴露给玩家,会把创作过程变成专业数据填表
## 3.3 专业化、规则化的任务
@@ -223,7 +223,7 @@
原因:
- 这些工作要么重复、要么专业、要么容易做脏活累活
- 让非专业创作者处理,会显著提高门槛,却不一定显著提高质量
- 让非专业百梦主处理,会显著提高门槛,却不一定显著提高质量
## 3.4 一致性、纠错与查漏补缺
@@ -240,15 +240,15 @@
原因:
- 这是 AI 比人更适合做的“维护型工作”
- 它属于创作支持,不属于创作者必须亲手完成的创作
- 它属于创作支持,不属于百梦主必须亲手完成的创作
## 4. 最合理的边界不是二分法,而是三层分工
自定义世界最合理的结构不是“玩家写”与“AI 写”的简单二选一,而是三层。
## 4.1 第一层:创作者必控层
## 4.1 第一层:百梦主必控层
这一层必须给创作者高自由度,且能被锁定:
这一层必须给百梦主高自由度,且能被锁定:
- 世界核心命题
- 主题与气质
@@ -264,9 +264,9 @@
**少而重。**
## 4.2 第二层:创作者可选强化层
## 4.2 第二层:百梦主可选强化层
这一层不应强制填写,但应该允许创作者继续深挖:
这一层不应强制填写,但应该允许百梦主继续深挖:
- 明线 / 暗线种子
- 角色之间的旧事
@@ -301,17 +301,17 @@
## 5. 具体模块的建议归属
| 模块 | 建议归属 | 创作者应控制什么 | AI / 系统应负责什么 |
| 模块 | 建议归属 | 百梦主应控制什么 | AI / 系统应负责什么 |
| --- | --- | --- | --- |
| 世界一句话设定、核心幻想、核心卖点 | 创作者直接控制 | 直接写、直接改、可锁定 | 给出备选表述和扩展方向 |
| 主题、基调、审美、禁忌 | 创作者直接控制 | 选择 / 改写 / 锁定 | 生成风格词、避雷词、提示词约束 |
| 玩家身份、开局处境、玩家目标 | 创作者直接控制 | 直接定义 | 补足开局钩子和初始叙事包装 |
| 关键势力与核心冲突 | 创作者主控AI 辅助 | 定义核心关系和立场 | 扩展冲突支路、生成世界线程 |
| 关键角色 | 创作者主控AI 辅助 | 定义角色骨架、关系张力、秘密方向 | 生成长背景、章节拆分、技能、物品、叙事档案 |
| 关键地点 | 创作者主控AI 辅助 | 定义地点意义、气氛、秘密 | 扩展场景细节、连接关系、遭遇分布 |
| 标志性物件 / 怪物 / 制度 / 规则 | 创作者主控AI 辅助 | 定义代表性要素与硬边界 | 扩展变体、命名、说明、运行时挂钩 |
| 世界一句话设定、核心幻想、核心卖点 | 百梦主直接控制 | 直接写、直接改、可锁定 | 给出备选表述和扩展方向 |
| 主题、基调、审美、禁忌 | 百梦主直接控制 | 选择 / 改写 / 锁定 | 生成风格词、避雷词、提示词约束 |
| 玩家身份、开局处境、玩家目标 | 百梦主直接控制 | 直接定义 | 补足开局钩子和初始叙事包装 |
| 关键势力与核心冲突 | 百梦主控AI 辅助 | 定义核心关系和立场 | 扩展冲突支路、生成世界线程 |
| 关键角色 | 百梦主控AI 辅助 | 定义角色骨架、关系张力、秘密方向 | 生成长背景、章节拆分、技能、物品、叙事档案 |
| 关键地点 | 百梦主控AI 辅助 | 定义地点意义、气氛、秘密 | 扩展场景细节、连接关系、遭遇分布 |
| 标志性物件 / 怪物 / 制度 / 规则 | 百梦主控AI 辅助 | 定义代表性要素与硬边界 | 扩展变体、命名、说明、运行时挂钩 |
| 普通 NPC / 路人 / 杂兵 / 次级地点 | 主要交给 AI | 仅在需要时抽查或替换 | 批量生成与风格保持 |
| 角色长背景、章节 teaser、context snippet | 主要交给 AI | 创作者只改关键角色即可 | 自动拆章、压缩、解锁节奏整理 |
| 角色长背景、章节 teaser、context snippet | 主要交给 AI | 百梦主只改关键角色即可 | 自动拆章、压缩、解锁节奏整理 |
| 技能、初始物品、标签、构筑倾向 | 主要交给 AI / 系统 | 提供偏好或少量 override | 按角色和世界规则自动编译 |
| 世界图谱、知识事实、可见性、导演指令 | AI / 系统内部层 | 不应默认暴露给玩家 | 运行时编译与维护 |
| 一致性检查、冲突检查、越权检查 | AI / 系统内部层 | 查看报告、决定是否采纳修改 | 自动扫描并提出修正建议 |
@@ -328,7 +328,7 @@
- 精确数值型 build 倾向
- 复杂掉落预算
更合理的做法是让创作者填写直觉表达,例如:
更合理的做法是让百梦主填写直觉表达,例如:
- `初见就戒备`
- `容易合作`
@@ -351,7 +351,7 @@
原因:
- 这些字段属于系统运行结构,不属于创作者自然的创作语言
- 这些字段属于系统运行结构,不属于百梦主自然的创作语言
- 直接让玩家填,会把工具变成只有懂系统的人才能用
## 6.3 不应该要求玩家逐个补完所有人物设定字段
@@ -378,7 +378,7 @@
## 7. 推荐的创作输入形态
要让非专业创作者也能高自由度创作,输入形态必须改成“自然语言创作卡”,而不是“系统字段表单”。
要让非专业百梦主也能高自由度创作,输入形态必须改成“自然语言创作卡”,而不是“系统字段表单”。
## 7.1 世界层卡片
@@ -397,10 +397,10 @@
## 7.2 每张卡片都允许 3 种输入方式
1. 一句话自由输入
- 适合低门槛创作者
- 适合低门槛百梦主
2. 标签 / 选项 / 语气滑条
- 适合不想写太多字的创作者
- 适合不想写太多字的百梦主
3. 高级补充
- 适合愿意继续深挖的人
@@ -414,7 +414,7 @@
这是高创作自由度里非常关键的一点。
创作者应当能:
百梦主应当能:
- 锁定一个角色
- 锁定一个地点
@@ -422,13 +422,13 @@
- 只重生成未锁定部分
- 围绕锁定内容重写其余世界
否则创作者每次调用 AI都会有“好不容易想好的东西被洗掉”的感受。
否则百梦主每次调用 AI都会有“好不容易想好的东西被洗掉”的感受。
## 8. 面向当前仓库的结构映射建议
为了便于后续落实现有系统,这份边界建议可以直接映射到当前结构:
## 8.1 创作者输入层
## 8.1 百梦主输入层
建议主要映射到:
@@ -445,7 +445,7 @@
## 8.2 AI 编译层
由 AI / 系统从创作者输入自动补出:
由 AI / 系统从百梦主输入自动补出:
- `themePack`
- `storyGraph`
@@ -465,7 +465,7 @@
- `CarrierStoryFingerprint`
- `StorySignal`
这些内容应该是“系统如何把世界跑起来”,不是“创作者必须亲手写完的创作内容”。
这些内容应该是“系统如何把世界跑起来”,不是“百梦主必须亲手写完的创作内容”。
## 9. 产品层面的最终结论
@@ -480,12 +480,12 @@
它应该做成这样:
1. 创作者决定世界的灵魂锚点。
2. 创作者重点塑造少量关键人、关键地、关键冲突、关键物。
1. 百梦主决定世界的灵魂锚点。
2. 百梦主重点塑造少量关键人、关键地、关键冲突、关键物。
3. AI 围绕这些锚点批量展开长尾内容。
4. 系统把这些内容编译成可运行的图谱、可见性、任务、物件和关系结构。
5. 创作者随时可以锁定核心创意,并局部重生成其余部分。
5. 百梦主随时可以锁定核心创意,并局部重生成其余部分。
一句话收束:
**创作者应该写“这个世界为什么动人”AI 应该负责“让这个世界长出来并跑起来”。**
**百梦主应该写“这个世界为什么动人”AI 应该负责“让这个世界长出来并跑起来”。**

View File

@@ -6,17 +6,17 @@
这份文档用于回答一个更具体的问题:
**参考 RPG 专业剧情策划全流程后,在自定义世界创作工具里,哪些设定必须要求创作者手动填写,哪些设定应该由 AI 先生成但允许创作者修改,哪些设定应完全交给系统托管,才能在“尽可能降低门槛”和“尽可能提高作品质量”之间取一个平衡。**
**参考 RPG 专业剧情策划全流程后,在自定义世界创作工具里,哪些设定必须要求百梦主手动填写,哪些设定应该由 AI 先生成但允许百梦主修改,哪些设定应完全交给系统托管,才能在“尽可能降低门槛”和“尽可能提高作品质量”之间取一个平衡。**
这份文档不再只回答“创作者与 AI 怎么分工”,而是进一步把创作工作台收束成一个更可执行的三层输入结构:
这份文档不再只回答“百梦主与 AI 怎么分工”,而是进一步把创作工作台收束成一个更可执行的三层输入结构:
1. 创作者必须手填的高杠杆锚点
2. AI 先生成、创作者可修改的内容草稿层
1. 百梦主必须手填的高杠杆锚点
2. AI 先生成、百梦主可修改的内容草稿层
3. 系统自动编译和运行的托管层
一句话结论:
**让创作者只负责决定作品的灵魂、视角、冲突和关系钩子,让 AI 负责把这些锚点展开成可编辑的剧情草稿,让系统负责把草稿编译成可运行的结构。**
**让百梦主只负责决定作品的灵魂、视角、冲突和关系钩子,让 AI 负责把这些锚点展开成可编辑的剧情草稿,让系统负责把草稿编译成可运行的结构。**
---
@@ -25,27 +25,27 @@
这套平衡设计要同时满足 5 个目标:
1. 低门槛
-创作者不需要写长篇设定,也不需要理解底层系统结构。
-百梦主不需要写长篇设定,也不需要理解底层系统结构。
2. 高辨识度
- 创作者写出来的世界,不应该只是“像一个世界”,而应该保留明显的个人方向。
- 百梦主写出来的世界,不应该只是“像一个世界”,而应该保留明显的个人方向。
3. 高可编辑性
- AI 不能一次生成后就不可控,创作者必须能改关键对象、关键关系和关键章节。
- AI 不能一次生成后就不可控,百梦主必须能改关键对象、关键关系和关键章节。
4. 高稳定性
- 任务、章节、关系、物件和可见性等运行层结构不能依赖创作者手填专业字段。
- 任务、章节、关系、物件和可见性等运行层结构不能依赖百梦主手填专业字段。
5. 可扩展
- 愿意深挖的创作者可以继续补充世界上限,不愿深挖的人也能快速产出质量不错的作品。
- 愿意深挖的百梦主可以继续补充世界上限,不愿深挖的人也能快速产出质量不错的作品。
---
## 2. 核心原则
## 2.1 创作者手填的必须是“高杠杆决策”,不是“高工作量字段”
## 2.1 百梦主手填的必须是“高杠杆决策”,不是“高工作量字段”
应该要求创作者手填的内容,必须同时满足下面两个条件:
应该要求百梦主手填的内容,必须同时满足下面两个条件:
1. 会显著决定作品气质和辨识度
2. AI 很难替代判断
@@ -67,9 +67,9 @@
- 章节拆分
- 运行时信号结构
## 2.2 创作者可改层应该承接“专业策划初稿”,而不是“原始底层字段”
## 2.2 百梦主可改层应该承接“专业策划初稿”,而不是“原始底层字段”
AI 生成后允许创作者修改的,不应该是一堆技术型字段,而应该是一批已经成形的内容卡片,例如:
AI 生成后允许百梦主修改的,不应该是一堆技术型字段,而应该是一批已经成形的内容卡片,例如:
- 关键角色卡
- 势力卡
@@ -81,11 +81,11 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
也就是说:
**AI 先给创作者一个像策划初稿的东西,而不是给一堆系统字段让创作者自己拼。**
**AI 先给百梦主一个像策划初稿的东西,而不是给一堆系统字段让百梦主自己拼。**
## 2.3 系统托管层必须彻底隐藏专业运行结构
以下这类结构不应该默认要求创作者理解或编辑:
以下这类结构不应该默认要求百梦主理解或编辑:
- `ThemePack`
- `WorldStoryGraph`
@@ -98,7 +98,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
- 稀有度映射
- 掉落和 build 权重
创作者应该编辑的是自然语言与内容卡,而不是运行时图结构。
百梦主应该编辑的是自然语言与内容卡,而不是运行时图结构。
## 2.4 先少量必填,再逐层展开
@@ -107,9 +107,9 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
```text
先填最小必填卡
-> AI 生成世界初稿
-> 创作者修改关键对象
-> 百梦主修改关键对象
-> 系统继续展开长尾
-> 创作者决定是否进入高级补充
-> 百梦主决定是否进入高级补充
```
## 2.5 默认清爽,深度能力后置
@@ -127,27 +127,27 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 3. 最终建议:三层分工
## 3.1 第一层:必须要求创作者手动填写
## 3.1 第一层:必须要求百梦主手动填写
这一层只保留最影响作品质量的高杠杆锚点,建议默认强制填写 6 张卡。
## 3.2 第二层AI 生成后支持创作者修改
## 3.2 第二层AI 生成后支持百梦主修改
这一层由 AI 根据第一层锚点自动展开成专业剧情策划初稿,创作者可以逐项修改、锁定、局部重生成。
这一层由 AI 根据第一层锚点自动展开成专业剧情策划初稿,百梦主可以逐项修改、锁定、局部重生成。
## 3.3 第三层:其余都交给系统
这一层是把前两层编译成可运行游戏结构所需的系统字段、数值和运行时指令,默认不要求创作者处理。
这一层是把前两层编译成可运行游戏结构所需的系统字段、数值和运行时指令,默认不要求百梦主处理。
---
## 4. 最低门槛方案:只强制手填 6 张卡
如果目标是尽可能降低门槛,同时又保留作品辨识度,建议只强制创作者填写以下 6 张卡。
如果目标是尽可能降低门槛,同时又保留作品辨识度,建议只强制百梦主填写以下 6 张卡。
## 4.1 卡 1世界一句话与核心幻想
创作者必须手填:
百梦主必须手填:
- 世界一句话设定
- 玩家来到这个世界最想体验的感觉
@@ -165,7 +165,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 4.2 卡 2玩家身份与开局困境
创作者必须手填:
百梦主必须手填:
- 玩家是谁
- 玩家开局最缺什么
@@ -179,7 +179,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 4.3 卡 3主题气质与禁忌边界
创作者必须手填:
百梦主必须手填:
- 主题关键词
- 情绪基调
@@ -199,7 +199,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 4.4 卡 4核心冲突
创作者必须手填:
百梦主必须手填:
- 当前世界最重要的 `1~3` 个明面冲突
- 至少 `1` 个隐藏问题或暗面危机
@@ -212,9 +212,9 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 4.5 卡 5关键关系钩子
这里不强制创作者一开始填写完整角色档案,只要求填写更高杠杆的“关系骨架”。
这里不强制百梦主一开始填写完整角色档案,只要求填写更高杠杆的“关系骨架”。
创作者必须手填:
百梦主必须手填:
- `2~4` 条关键关系钩子
- 每条钩子至少说明:
@@ -229,7 +229,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 4.6 卡 6标志性要素与硬规则
创作者必须手填:
百梦主必须手填:
- `2~5` 个标志性要素
- 物件
@@ -247,11 +247,11 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
---
## 5. 不建议强制手填,但应该让 AI 生成后支持创作者修改的设定
## 5. 不建议强制手填,但应该让 AI 生成后支持百梦主修改的设定
这一层是平衡“低门槛”和“高质量”的关键。
创作者不需要从零填写这些内容,但 AI 生成后必须能看、能改、能锁定、能局部重生成。
百梦主不需要从零填写这些内容,但 AI 生成后必须能看、能改、能锁定、能局部重生成。
## 5.1 世界外观层
@@ -282,7 +282,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因:
- 势力很重要,但让新手一开始手写完整势力表太重
- 更合理的做法是让 AI 基于核心冲突先出草稿,再由创作者修正
- 更合理的做法是让 AI 基于核心冲突先出草稿,再由百梦主修正
## 5.3 关键角色层
@@ -302,8 +302,8 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因:
- 创作者已经通过“关系钩子”给出最关键的人物骨架
- AI 负责把钩子展开成可编辑角色卡,创作者再做精修
- 百梦主已经通过“关系钩子”给出最关键的人物骨架
- AI 负责把钩子展开成可编辑角色卡,百梦主再做精修
## 5.4 关键地点层
@@ -319,7 +319,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因:
- 地点是世界感的重要来源
- 但新创作者未必能一开始就写出完整地点网络
- 但新百梦主未必能一开始就写出完整地点网络
## 5.5 世界线程层
@@ -335,7 +335,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因:
- 线程是专业剧情结构,适合 AI 先搭骨架
-创作者必须有权修正哪条线更重要、哪条线该隐藏
-百梦主必须有权修正哪条线更重要、哪条线该隐藏
## 5.6 主线章节层
@@ -350,9 +350,9 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因:
- 创作者已经给出了世界目标、冲突和关系
- 百梦主已经给出了世界目标、冲突和关系
- AI 可以先把它们编成主线章节初稿
- 创作者再选择保留、删减或重排
- 百梦主再选择保留、删减或重排
## 5.7 支线、角色线、阵营线层
@@ -367,7 +367,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因:
- 这是最适合 AI 拉开内容宽度的部分
- 也是最需要创作者局部精修的部分
- 也是最需要百梦主局部精修的部分
## 5.8 场景章节层
@@ -384,7 +384,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因:
- 当前项目已经在走“场景 = 章节单元”的方向
- 这层非常适合 AI 编排出第一版,再由创作者补强记忆点
- 这层非常适合 AI 编排出第一版,再由百梦主补强记忆点
## 5.9 叙事载体层
@@ -397,7 +397,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
- 场景遗物
- 怪物命名及其故事指向
创作者主要修改:
百梦主主要修改:
- 哪些载体最重要
- 哪些载体和哪条线程绑定
@@ -417,13 +417,13 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因:
- 这些内容适合 AI 批量铺量
- 创作者只需要挑、改、锁定,不必从零起草
- 百梦主只需要挑、改、锁定,不必从零起草
---
## 6. 其余设定应交给系统托管
以下内容不建议默认暴露给创作者编辑,应由系统根据前两层自动编译和维护。
以下内容不建议默认暴露给百梦主编辑,应由系统根据前两层自动编译和维护。
## 6.1 题材与术语编译层
@@ -450,7 +450,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因:
- 创作者要的是“故事线能对”,不是维护图数据库
- 百梦主要的是“故事线能对”,不是维护图数据库
## 6.3 可见性和 prompt 裁剪层
@@ -465,7 +465,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
原因:
- 这层必须稳定、严格、自动化
- 不适合依赖创作者手动维护
- 不适合依赖百梦主手动维护
## 6.4 运行时导演层
@@ -494,7 +494,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
说明:
- 创作者可以编辑“任务卡”和“章节卡”
- 百梦主可以编辑“任务卡”和“章节卡”
- 但不应默认编辑底层 contract 结构
## 6.6 数值与配置层
@@ -511,7 +511,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
说明:
- 创作者可以给“偏向”
- 百梦主可以给“偏向”
- 系统负责编译成具体数值
## 6.7 QA 与一致性层
@@ -547,7 +547,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
| 主线 | 不强制首轮手写完整主线 | 幕结构、章节卡、高潮与 handoff | 章节状态编译 |
| 支线/角色线 | 不强制首轮手写完整矩阵 | 支线种子、角色线事件、阵营线分歧 | 任务 contract 编译 |
| 场景章节 | 不强制首轮手写全量章节 | 场景章节卡、阶段内容、章节载体 | signal 与导演层 |
| 运行时结构 | 不建议创作者接触 | 不建议默认编辑 | 可见性、导演、信号、编译、QA |
| 运行时结构 | 不建议百梦主接触 | 不建议默认编辑 | 可见性、导演、信号、编译、QA |
---
@@ -555,7 +555,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 8.1 第一步:只填写最小必填集
创作者只需要完成:
百梦主只需要完成:
1. 世界一句话与核心幻想
2. 玩家身份与开局困境
@@ -584,9 +584,9 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
这里的重点不是一次补满全世界,而是先形成一个像样的内容骨架。
## 8.3 第三步:创作者只精修高价值卡片
## 8.3 第三步:百梦主只精修高价值卡片
建议默认优先让创作者编辑这 4 类卡片:
建议默认优先让百梦主编辑这 4 类卡片:
1. 关键角色
2. 核心冲突与线程
@@ -606,7 +606,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
- 任务包装
- 文案变体
## 8.5 第五步:创作者按需进入高级模式
## 8.5 第五步:百梦主按需进入高级模式
高级模式只对愿意深挖的人开放:
@@ -665,7 +665,7 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
## 10.2 每张卡只保留自然语言输入
不要强迫创作者在首轮填写:
不要强迫百梦主在首轮填写:
- tags
- ids
@@ -676,20 +676,20 @@ AI 生成后允许创作者修改的,不应该是一堆技术型字段,而
更合理的做法是:
-创作者输入自然语言或选择直觉标签
-百梦主输入自然语言或选择直觉标签
- 再由系统编译成结构化字段
## 10.3 首轮生成后默认先看“精修建议”
AI 初稿生成后,不应该把创作者直接扔进一个大编辑器。
AI 初稿生成后,不应该把百梦主直接扔进一个大编辑器。
更好的做法是先给出:
1. 哪些卡片最值得改
2. 哪些内容已经比较稳定
3. 哪些内容仍然偏泛,需要创作者补个性
3. 哪些内容仍然偏泛,需要百梦主补个性
这样能明显提高创作者的修改效率。
这样能明显提高百梦主的修改效率。
## 10.4 移动端优先只保留高杠杆操作
@@ -707,15 +707,15 @@ AI 初稿生成后,不应该把创作者直接扔进一个大编辑器。
## 11. 最后结论
如果目标是在自定义世界创作中真正平衡“降低门槛”和“提高作品质量”,最好的做法不是让创作者填更多字段,也不是把一切都交给 AI。
如果目标是在自定义世界创作中真正平衡“降低门槛”和“提高作品质量”,最好的做法不是让百梦主填更多字段,也不是把一切都交给 AI。
更合理的平衡是:
1. 创作者必须手填最小但高杠杆的 6 张卡,掌握世界灵魂。
1. 百梦主必须手填最小但高杠杆的 6 张卡,掌握世界灵魂。
2. AI 根据这 6 张卡生成一套可编辑的专业剧情初稿,负责把骨架展开成角色、地点、线程、章节和载体。
3. 创作者只精修最有价值的关键对象,锁定真正重要的内容。
3. 百梦主只精修最有价值的关键对象,锁定真正重要的内容。
4. 其余运行结构、数值、可见性、任务编译和 QA 检查都交给系统托管。
一句话收束:
**创作者负责决定“这个世界为什么值得被创作”AI 负责把它整理成可修改的策划初稿,系统负责把它稳定地跑成一个游戏世界。**
**百梦主负责决定“这个世界为什么值得被创作”AI 负责把它整理成可修改的策划初稿,系统负责把它稳定地跑成一个游戏世界。**

View File

@@ -10,7 +10,7 @@
- 基于“最小必填锚点 + AI 初稿卡片 + 系统托管层”的结构化创作方案
2. 纯 Agent 式方向
- 以前台对话为唯一主交互,创作者主要通过和 Agent 聊天来完成世界构建、角色塑造、剧情扩展和修改
- 以前台对话为唯一主交互,百梦主主要通过和 Agent 聊天来完成世界构建、角色塑造、剧情扩展和修改
文档需要回答 3 个问题:
@@ -34,7 +34,7 @@
当前方案的核心是:
1. 创作者手填最小高杠杆锚点
1. 百梦主手填最小高杠杆锚点
2. AI 生成一批可编辑的剧情策划初稿卡片
3. 系统把内容编译成运行时结构
@@ -42,7 +42,7 @@
**结构化工作台 + AI 协作生成。**
创作者的主要行为是:
百梦主的主要行为是:
1. 填写关键卡片
2. 修改关键角色、地点、势力、章节等内容卡
@@ -53,9 +53,9 @@
纯 Agent 式不是指“系统内部没有结构”,而是指:
**创作者前台几乎不需要面对表单和卡片编辑器,主要通过自然语言对话来完成创作。**
**百梦主前台几乎不需要面对表单和卡片编辑器,主要通过自然语言对话来完成创作。**
创作者的主要行为变成:
百梦主的主要行为变成:
1. 用自然语言描述世界想法
2. 回答 Agent 的追问
@@ -77,7 +77,7 @@
1. 前台用户主要通过什么方式思考和输入?
2. 后台系统是否仍然有稳定的世界模型和编译层?
3. 创作者是否还能看见摘要、锁定内容和修改范围?
3. 百梦主是否还能看见摘要、锁定内容和修改范围?
对当前项目来说,真正危险的不是“转成聊天”,而是:
@@ -93,11 +93,11 @@
它更擅长:
1. 帮不擅长表单和结构思考的创作者起步
2.创作者思路模糊时做追问和陪创作
1. 帮不擅长表单和结构思考的百梦主起步
2.百梦主思路模糊时做追问和陪创作
3. 把“我要做一个世界”变成一次自然聊天
4. 动态决定追问深度,而不是一上来摆很多字段
5.创作者感觉自己是在和一个懂 RPG 的剧情搭档共创
5.百梦主感觉自己是在和一个懂 RPG 的剧情搭档共创
## 2.2 纯 Agent 式的主要问题
@@ -110,7 +110,7 @@
1. 聊天很多,但世界状态越来越难总览
2. 角色、地点、势力和章节信息散落在多轮消息里
3. 锁定范围不清,重生成容易误伤已有内容
4. Agent 很容易“替创作者决定太多”
4. Agent 很容易“替百梦主决定太多”
5. 长会话越来越贵,越来越慢,也越来越容易漂移
## 2.3 对当前项目的判断
@@ -197,7 +197,7 @@
纯 Agent 式更弱的地方在于:
1. 世界模型隐藏得太深时,创作者会失去整体掌控感
1. 世界模型隐藏得太深时,百梦主会失去整体掌控感
2. 多轮对话后,已确定内容不容易被清晰回看
3. 局部重做和精确编辑边界会变模糊
4. Agent 容易过度代写、过度主导
@@ -223,7 +223,7 @@
因为这些环节的关键问题不是“字段如何摆放”,而是:
**创作者有没有被真正引导出自己想做的世界。**
**百梦主有没有被真正引导出自己想做的世界。**
## 4.2 不值得直接转成纯聊天黑箱的部分
@@ -261,8 +261,8 @@
即使转成纯 Agent 式,也仍然要保留这三层:
1. 创作者必须确认的高杠杆锚点
2. AI 生成但允许创作者修改的策划初稿层
1. 百梦主必须确认的高杠杆锚点
2. AI 生成但允许百梦主修改的策划初稿层
3. 系统托管的运行时编译层
变化的只是:
@@ -339,7 +339,7 @@
2. 会阶段性总结
3. 会把聊天结果沉淀成结构化世界状态
4. 会提醒风险和冲突
5. 会在创作者要求时进行局部重写和定向扩展
5. 会在百梦主要求时进行局部重写和定向扩展
## 6.2 正确理解
@@ -349,7 +349,7 @@
也就是说:
1. 创作者看到的是对话
1. 百梦主看到的是对话
2. 系统内部维护的是世界模型、锁定状态、摘要和编译结果
---
@@ -389,7 +389,7 @@ Agent 首轮不应该直接铺满全世界,而应该给出一份简明底稿
2. 建议内容
3. 待确认内容
## 7.3 阶段 C创作者锁定锚点
## 7.3 阶段 C百梦主锁定锚点
在纯 Agent 模式里,锁定行为必须被显式支持。
@@ -455,7 +455,7 @@ Agent 不应该每轮都继续扩全局,而应该支持“单对象工作模
| 结构 | 作用 |
| --- | --- |
| `creatorIntentProfile` | 当前创作者最初和最新的创作意图 |
| `creatorIntentProfile` | 当前百梦主最初和最新的创作意图 |
| `lockedAnchors` | 已确认不可自动改写的内容 |
| `worldDraftSnapshot` | 当前世界底稿快照 |
| `editableDraftCards` | 角色、地点、势力、章节等可编辑初稿 |
@@ -530,7 +530,7 @@ Agent 不能像问卷系统,也不能一次追问太多。
1. 一次最多追问 `1~3` 个问题
2. 问题必须是当前最缺的高杠杆信息
3. 每次追问都给默认建议方向
4. 如果创作者不想细答,允许 Agent 先代补一个版本再确认
4. 如果百梦主不想细答,允许 Agent 先代补一个版本再确认
这样才能保持“像聊天”,而不是“像客服表单”。
@@ -614,14 +614,14 @@ Agent 应能识别这些常见修改类型:
3. 锁定内容固定展示
4. 提供“当前世界圣经”入口
## 11.2 风险 2Agent 过度代写,创作者失去作品归属感
## 11.2 风险 2Agent 过度代写,百梦主失去作品归属感
防护方式:
1. 高杠杆锚点必须要求确认
2. 重要改动前先说“我准备改什么”
3. 默认优先给多个候选,而不是直接盖写
4. 允许创作者随时回退到旧版本
4. 允许百梦主随时回退到旧版本
## 11.3 风险 3局部修改带出全局漂移

View File

@@ -37,8 +37,8 @@
- 不能先删旧字段,再补新结构。
- 必须先补新设定层,再逐步迁读,最后再让旧模板字段退化成兼容层。
4. 不能增加创作者负担
- 这次不是让创作者多填一堆底层 schema。
4. 不能增加百梦主负担
- 这次不是让百梦主多填一堆底层 schema。
- 这些设定仍然应由 AI / 系统编译出来,只是所有权从模板世界转移到自定义世界自己。
---

View File

@@ -102,9 +102,9 @@
这不是真正跨题材,只是换了名字。
## 3.3 不能让创作者承担更多底层配置工作
## 3.3 不能让百梦主承担更多底层配置工作
这次优化不是让创作者额外填写:
这次优化不是让百梦主额外填写:
- 怪物模板表
- 场景参考池

View File

@@ -349,7 +349,7 @@ export interface ChapterProgressionPlan {
}
```
建议作为后端运行时编译结果缓存,不作为创作者直接编辑字段。
建议作为后端运行时编译结果缓存,不作为百梦主直接编辑字段。
## 3.7 章节经验记账
@@ -636,7 +636,7 @@ chapterXpBudget =
3. 非主角色友方 NPC
- `support``ambient`
如需修正,再允许章节蓝图加可选 override但不要求创作者每次手填。
如需修正,再允许章节蓝图加可选 override但不要求百梦主每次手填。
## 7.2 等级锚点

View File

@@ -17,10 +17,14 @@
3. 卡片高度控制在约 4rem 内,标题与状态信息并排组织,避免大留白。
4. 模块本体使用 `max-height: 33svh` 作为硬约束,内容超出时优先在模板入口行内横向滚动,不撑高页面。
5. 桌面端保持网格入口,但同步收紧内边距和卡片留白,避免移动端与桌面端表现割裂。
6. 横向滚动模板行必须隐藏原生滚动条,保留滑动能力,避免底部出现过粗的视觉条。
7. 模板入口排序以可创作为第一优先级:可创建卡片保持原配置内相对顺序排在前面,锁定且展示“敬请期待”的卡片保持原配置内相对顺序排在后面。
## 文案约束
- UI 不新增规则说明类文案。
- 原有“直接选择游戏创作模板,立刻进入对应的共创工作台。”说明在移动端隐藏,桌面端保留为辅助说明。
- 锁定、可创建、正在开启等状态继续来自既有模板元数据或忙碌状态
- 可创建的模板卡不展示“可创建”状态标签,只保留标题、短副标题和进入箭头
- 锁定的模板卡统一以“敬请期待”作为状态标注,不再显示“锁定”。
- RPG 入口展示为“角色扮演 / 剧情演绎,冒险成长”,拼图入口展示为“拼图 / 创意礼物,生活分享”。
- 忙碌状态仅保留在模块标题行的轻量状态中,避免占用每张可用卡片的首要视觉层级。

View File

@@ -0,0 +1,46 @@
# 移动端创作页作品列表统一卡片设计 2026-04-29
## 背景
创作页的作品模块需要同时承载 RPG、拼图和大鱼吃小鱼等玩法。不同玩法卡片不能各自展示阶段、素材、主题等细节标签否则作品列表会在移动端显得拥挤并且草稿作品会暴露过多编辑态信息。
本次将作品列表卡片收口成统一信息结构:草稿只用于快速识别和继续创作,已发布作品才展示公开数据与分享入口。
## 落地范围
- 列表容器:`src/components/custom-world-home/CustomWorldCreationHub.tsx`
- 作品卡片:`src/components/custom-world-home/CustomWorldWorkCard.tsx`
- 不改动作品数据聚合、筛选、打开和体验逻辑。
- 已发布作品右上角动作从删除改为分享;草稿仍保留删除入口。
## 卡片结构规则
1. 标题上方只显示两个标签:作品状态与游戏类型。
2. 不再显示阶段、主题、素材完成度、作者、作品号等额外标签。
3. 标签下方依次显示作品名称与作品描述。
4. 草稿卡片到作品描述为止,不显示其他统计、作品号或体验按钮。
5. 已发布卡片在描述下方显示三项公开指标:游玩数、改造数、点赞数。
6. 已发布卡片右上角显示分享 icon点击后复制作品分享文案不触发卡片打开。
7. 草稿卡片右上角继续显示删除 icon点击删除不触发卡片打开。
## 公开指标重点展示补充
1. 已发布作品的三项公开指标不得继续使用标签样式展示,必须参考作品详情页的统计区,采用“小标签 + 大数字 + 单位”的重点信息结构。
2. 指标文案统一为“游玩”“改造”“点赞”,不得在创作页卡片中展示 `Remix` 英文。
3. 用户每次进入创作页时,前端读取上一次进入该页面缓存的公开指标快照;当已发布作品卡片滑动进入视口后,数字从缓存值增长到本次接口返回的最新值。
4. 若最新值高于缓存值,动画完成后在对应指标右下角展示红色向上箭头和本次上涨的具体数值,字号低于主数字,避免抢占主信息层级。
5. 若没有缓存值、缓存值不低于最新值或作品仍是草稿,则直接显示最新值,不展示上涨标记。
6. 每张作品卡片继续使用作品封面作为整卡背景,封面需要有透明度和渐变遮罩,确保标题、描述和指标在亮色与暗色主题下都清晰可读。
## 移动端布局规则
1. 作品列表默认仍使用 2 列网格,保证草稿可以快速扫视。
2. 已发布作品卡片在移动端固定 `col-span-2`,即占据一整行,避免公开指标和分享入口互相挤压。
3. `sm` 及以上视口恢复普通网格跨度,由卡片自然进入多列布局。
4. 小屏卡片降低高度、内边距、标题字号和徽标尺寸,避免长标题或中文描述撑破容器。
## 文案约束
- 不新增功能说明类文案。
- 空态和错误态沿用现有文案。
- 中文标题、描述和指标需要在卡片内截断或换行,不得因长文本破坏布局。

View File

@@ -29,5 +29,5 @@
## 4. 验收点
- 平台“选择创作类型”弹层不再显示“大鱼吃小鱼”卡片。
- RPG、拼图、“敬请期待”类卡片顺序与交互保持稳定。
- 可创建卡片排在前面,展示“敬请期待”的锁定卡片排在后面,交互状态保持稳定。
- 代码层不引入对 Big Fish 运行时或结果页的额外耦合修改。

View File

@@ -0,0 +1,82 @@
# 平台首页分类入口与排行 Tab 调整设计
更新时间:`2026-04-29`
## 1. 本次目标
1. 首页移动端频道只保留“推荐、今日游戏、游戏分类”删除“PC游戏、即点即玩”。
2. 原底部“分类” Tab 改为“排行” Tab不再单独承载分类页。
3. 原分类 Tab 的标签筛选移动到首页移动端“游戏分类”频道中,作品展示从双列网格改为应用商店式纵向列表。
4. 排行页参考榜单式纵向布局,提供热门榜、改造榜、新品榜、点赞榜四个榜单切换。
5. 页面继续使用平台主题变量、现有字号层级与卡片组件,避免新增大段功能说明文案。
## 2. 数据口径
当前公开作品聚合列表已经透传后端读模型字段:
- `playCount`:历史游玩次数。
- `remixCount`:历史改造次数。
- `likeCount`:历史点赞次数。
- `recentPlayCount7d`:近 7 日新增游玩次数。
- `publishedAt / updatedAt`:发布时间或更新时间。
本次新增 `public_work_play_daily_stat` 日桶读模型,所有公开玩法的正式游玩入口在累加历史 `playCount` 时同步写入该表。公开列表返回时按作品聚合最近 7 个 UTC 自然日的 `recentPlayCount7d`,前端只负责展示与排序。
1. 热门榜按 `playCount` 降序。
2. 改造榜按 `remixCount` 降序。
3. 点赞榜按 `likeCount` 降序。
4. 新品榜按 `recentPlayCount7d` 降序。
## 3. 交互规则
### 3.1 首页移动端
- 顶部搜索框保持不变。
- 频道横滑 Tab 顺序为:推荐、今日游戏、游戏分类。
- 推荐展示精选与最新去重后的作品流。
- 今日游戏只展示 `publishedAt` 落在玩家当前浏览器自然日内的新发布公开作品;跨日旧作品即使仍在最新列表前排,也不能进入该频道。
- 游戏分类展示原分类页内容:筛选胶囊 + 横向标签 + 当前标签下纵向作品列表。
- 游戏分类列表参考移动应用商店结构,不再使用双列卡片:左侧方形封面,中间为作品名、状态角标、评分/题材、摘要或热度短句,右侧为“启动/试玩”主按钮。
- 分类频道的筛选区只保留短标签,不写功能说明文案;筛选按钮展示当前标签数量,横向标签展示可切换的分类入口。
### 3.2 底部导航
- 登录态:`首页 / 排行 / 创作 / 存档 / 我的`
- 未登录态:`首页 / 创作 / 排行`
- 底部排行入口仍复用原 `category` Tab 的路由值,减少导航状态迁移风险,但所有用户可见文案改为“排行”。
### 3.3 排行页
- 顶部为横向榜单 Tab热门榜、改造榜、新品榜、点赞榜。
- 下方为纵向榜单列表,每行展示排名、封面、作品名、榜单指标、玩法类别、两个标签与进入按钮。
- 公开作品名称在列表与卡片中统一限制为最多 8 字;公开作品标签统一限制为最多 4 字。
- 排行榜单条目正文固定为三行:第一行作品名,第二行榜单数据与玩法类别,第三行展示两个标签;不再显示发布时间、作者名等第四行信息。
- 无数据或加载中沿用现有短空态文案。
## 4. 编码落点
- `src/components/rpg-entry/RpgEntryHomeView.tsx`
- 精简首页频道枚举。
- 增加排行榜单构造、榜单切换状态与榜单行组件。
- 将分类内容移动到移动端首页“游戏分类”频道。
- 增加游戏分类纵向列表条目组件,替换移动端分类频道的双列作品网格。
- 将底部/桌面侧边导航文案从“分类”改为“排行”。
- `src/index.css`
- 增加榜单行、榜单切换按钮、游戏分类筛选栏和纵向列表条目的主题化样式。
- `server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 增加公开作品每日游玩统计表与 7 日聚合 helper。
- `server-rs/crates/spacetime-module/src/migration.rs`
- migration 表清单对齐 `public_work_play_daily_stat`
- `server-rs/crates/shared-contracts/src/*_works.rs``packages/shared/src/contracts/*`
- 公开作品响应补齐 `recentPlayCount7d`
## 5. 验收点
1. 移动端首页不再显示“PC游戏、即点即玩”。
2. 点击首页“游戏分类”能看到原分类标签与作品列表。
- 移动端分类作品必须为纵向列表,不能回退为两列网格。
- 单条作品在 390px 宽度下必须保持封面、标题、按钮同一行可扫读,摘要截断且不挤压右侧按钮。
3. 点击首页“今日游戏”只显示当天新发布作品;仅更新时间为今天但发布时间不在今天的作品不能进入今日频道。
4. 底部导航显示“排行”,不再显示“分类”。
5. 排行页可切换四个榜单,排序口径符合当前字段约束。
6. 不修改 server-node不新增 PostgreSQL 相关实现。

View File

@@ -0,0 +1,91 @@
# 平台首页移动端信息流与作品卡设计
更新时间:`2026-04-28`
## 1. 本次目标
1. 桌面端首页布局保持现有顶部栏、侧边导航、Hero、趋势区与下方网格结构不调整桌面端区块顺序。
2. 移动端首页改为参考图式信息流:顶部搜索框、横向频道 Tab、纵向作品列表、底部主导航。
3. 双端公开作品卡统一结构:上方 `16:9` 封面图,下方作品名称、作者信息、作品描述与玩法类型。
4. 点赞数必须来自作品读模型字段,前端只负责展示,不把游玩数或评分临时改名成点赞。
## 2. 数据契约
### 2.1 统一字段
公开作品卡和创作中心复用的作品摘要都增加:
```ts
likeCount: number;
```
当前阶段只做只读展示,不新增点赞按钮和点击 reducer。后端对尚未接入真实点赞表的作品返回 `0`,保证接口 shape 稳定,后续可无 UI 结构迁移地接入真实互动计数。
### 2.2 各玩法映射
1. RPG 公开广场:`CustomWorldLibraryEntry``CustomWorldGalleryCard` 返回 `likeCount`,当前由 Rust facade 返回 `0`
2. 拼图公开广场:`PuzzleWorkSummary` 返回 `likeCount`,当前由 Rust facade 返回 `0``playCount` 继续仅表示游玩次数。
3. 大鱼公开广场:`BigFishWorkSummary` 返回 `likeCount`,当前由 Rust facade 返回 `0``playCount` 继续仅表示游玩次数。
4. 前端聚合类型 `PlatformPublicGalleryCard` 透传 `likeCount``WorldCard` 不再依赖 `badge/metaLabel` 决定主要信息结构。
### 2.3 首页读链路核对
首页公开作品流的读取链路固定为:
```text
RpgEntryHomeView
→ platformPublicGalleryClient / puzzleGalleryClient / bigFishGalleryClient
→ Rust api-server
→ spacetime-client 生成绑定
→ spacetime-module procedure
→ SpacetimeDB 表
```
1. 公开读取必须匿名可用,前端 `GET` 列表与详情统一传 `skipAuth: true``skipRefresh: true`,避免未登录首页被刷新 token 链路阻断。
2. 拼图公开广场走 `list_puzzle_gallery` / `get_puzzle_gallery_detail`,返回 `coverImageSrc``summary``themeTags``playCount``remixCount``likeCount`
3. 大鱼公开广场走 `list_big_fish_works(published_only=true)`;由于部分已部署模块会在公开列表分支前仍校验 `owner_user_id` 非空,客户端与模块内部公共列表输入都使用 `public-big-fish-gallery` 占位 owner。该字段在 `published_only` 分支不参与筛选,只用于兼容旧校验。
4. 自定义世界公开广场走 `list_custom_world_gallery_entries`,当前主云数据为空时应返回成功空列表,而不是错误态。
### 2.4 作者头像读取策略
1. 首页“推荐”和“今日游戏”作品卡不把 `avatarUrl` 固化到作品读模型里,避免作者修改头像后旧作品卡继续展示过期头像。
2. 前端首页在聚合公开作品后,按作品摘要中的 `authorPublicUserCode` 优先读取公开用户摘要;没有公开用户码的玩法摘要,使用 `ownerUserId` 读取公开用户摘要。
3. 作者头像查询必须走匿名公开接口,并在首页组件内按作者维度缓存;单个作者在推荐与今日游戏中重复出现时不能重复请求。
4. 公开用户摘要返回 `avatarUrl` 时,作品卡展示真实头像;头像缺失、读取失败或作者身份字段缺失时,继续使用作者昵称首字占位。
## 3. 移动端布局
1. 移动端首页只在 `RpgEntryHomeView` 的 mobile content 内重排。
2. 第一屏顺序:
- 搜索框
- 频道横滑 Tab推荐、今日游戏、游戏分类、PC游戏、即点即玩
- 作品信息流
3. 作品信息流使用单列纵向列表,卡片宽度填满容器,卡片之间保留短间距。
4. 不新增功能说明类长文案;空态仍沿用现有短状态文案。
5. 移动端卡片视觉允许接近参考图的深色信息流,但仍走平台主题 token避免写死不可维护的大面积色块。
## 4. 作品卡结构
每张公开作品卡固定为:
1. 封面区域:`aspect-ratio: 16 / 9`,图片 `object-cover`;无封面时使用轻量主题底。
2. 封面左上角不展示“推荐”标签,也不展示作者昵称标签,避免遮挡作品主视觉。
3. 封面右下角展示三项轻量指标:游玩、改造、点赞;统一为图标 + 紧凑数字,例如 `128``1.2万`,不写额外说明长文案。
4. 信息区域:
- 第一行作品名称右侧展示玩法类型。拼图玩法展示“拼图”大鱼玩法展示“大鱼”RPG 作品展示题材短标签。
- 第二行:原副标题位置展示作者头像和昵称。当前公开列表只返回作者昵称时,头像使用昵称首字生成的轻量头像;后续接入作者头像 URL 后复用同一位置。
- 第三行:作品描述,两行截断。
- 第四行:最多三个标签。
5. 点赞数仍必须来自作品读模型字段,只是展示位置从信息区右侧迁移到封面右下角。
6. 不展示作品号;作品号仍只在详情页或分享路径中使用。
## 5. 验收
1. 390px 移动端首页不横向溢出,能看到搜索、频道和纵向作品列表。
2. 桌面端首页布局区块顺序不变,只替换公开作品卡内部结构。
3. RPG、拼图、大鱼三类公开作品卡都有 `likeCount` 字段,前端聚合后能统一展示。
4. 运行编码检查、前端定向测试和必要的 Rust 检查。
5. HTTP 验收需覆盖:
- `GET /api/runtime/custom-world-gallery` 成功返回 `entries`
- `GET /api/runtime/puzzle/gallery` 成功返回 `items` 且包含 `likeCount`
- `GET /api/runtime/big-fish/gallery` 成功返回 `items`,旧部署模块不再因 `big_fish.owner_user_id 不能为空` 阻断首页。

View File

@@ -96,7 +96,7 @@
- 同时开放短信与密码登录时,面板顶部展示两个居中的文字页签,当前页签使用深色字重和短下划线强调。
- 只渲染当前页签对应的输入区;切换页签不弹出新面板,不展示二维码入口。
- `短信登录` 页签包含手机号、验证码、获取验证码和主按钮。
- `密码登录` 页签只包含手机号、密码、主按钮和忘记密码入口;不支持邮箱、用户名或叙世号。
- `密码登录` 页签只包含手机号、密码、主按钮和忘记密码入口;不支持邮箱、用户名或百梦号。
- 密码登录只是手机号验证码登录的补充方式:只有已登录并设置过密码的手机号账号才能使用,不能在密码页签创建账号。
- `密码登录` 主按钮固定为 `登录`,不得使用 `注册/登录`
- 未开放某个登录方式时不展示对应页签,避免用户进入不可用表单。

View File

@@ -59,8 +59,8 @@
### 3.2 排版
- 平台层正文、按钮、说明、功能标签统一使用非像素字体
- 左上角 `叙世 / GENARRATIVE` 品牌字标允许单独做成像素化 logo
- `GENARRATIVE``叙世` 都优先直接使用游戏内同款 `Fusion Pixel`
- 左上角 `百梦 / GENARRATIVE` 品牌字标允许单独做成像素化 logo
- `GENARRATIVE``百梦` 都优先直接使用游戏内同款 `Fusion Pixel`
- 品牌字标默认保持正常像素字观感,禁止再叠双层粗阴影或手动加粗到影响识别
- 品牌字标直接使用字体文件内原字形,不额外做运行时描字、轮廓拼字或伪粗体处理
- 主标题保留明显层级,但不再做像素描边效果

View File

@@ -0,0 +1,106 @@
# 平台统一作品详情页与 Remix 数据链路设计
更新时间:`2026-05-01`
## 1. 本次目标
1. 平台首页、公开广场、分类列表中的每个公开作品点击后,统一先进入作品详情页,不再直接启动玩法。
2. 作品详情页结构参考 TapTap 详情页:顶部封面图、作品基础信息、右侧“点赞”按钮、四项统计、简介内容、底部“作品改造 + 启动”同行动作。
3. 删除参考图顶部 Tab不接入评价和论坛功能不展示“开发者的话”模块。
4. 统计数据必须从数据库读模型贯穿到前端展示,禁止在前端用假字段、游玩数冒充点赞数或固定文案代替真实字段。
5. Remix 按钮必须由后端事务复制公开作品为当前用户草稿,并同步增加原作品改造次数,成功后前端进入新草稿详情/结果页。
## 2. 详情页 UI 结构
统一详情页只做作品展示与动作入口,不承担规则说明。
1. 顶部导航:返回按钮、标题“详情”、更多按钮占位;不展示“统计 / 详情 / 评价 / 论坛”Tab。
2. 封面区:固定 `16:9` 比例,默认使用作品封面图 `cover` 填满整块主视觉;背景可用同图弱化铺底;缺图时只显示平台主题底,不新增说明文字。拼图作品详情页若详情数据包含多个关卡图,则顶部封面区优先按关卡正式图轮播展示,每张图对应一个关卡;无可用关卡图时再回退作品封面图。拼图多关封面只默认展示第一张真实图,轮播节奏与左右切换保持不变;未解锁的后续封面必须使用同图毛玻璃模糊底和大问号图标遮罩,不能展示真实清晰图,也不能追加规则说明文字。玩家完成对应前置关卡后,当前详情页可按本次 `PuzzleRunSnapshot.clearedLevelCount + 1` 即时解锁可见封面数;刷新后持久化解锁应由后端从当前用户的拼图运行记录汇总到详情读模型,前端只消费读模型或当前 run 状态。
3. 移动端首页“推荐”和“今日游戏”列表中,只有最接近屏幕垂直中心的作品卡片进入封面轮播态;若该拼图作品有多张关卡封面,则按详情页同源封面序列自动轮换。用户滚动后,离开中心的旧卡片必须立即恢复首张封面,新中心卡片再开始轮播;“游戏分类”、排行、桌面端列表不启用该自动轮播。
4. 基础信息区:
- 左侧作品图标使用作品封面序列首图;顶部封面轮播切换时,该正方形图标保持首图不变,避免作品名称旁的身份标识跟随大图闪动。
- 中间展示作品名、作者头像、作者名、玩法类型;作者头像读取公开用户资料 `avatarUrl`,缺失时使用作者昵称首字占位。
- 右侧原 TapTap 评分位置替换为 `点赞` 按钮;点击后调用后端点赞接口,由后端记录当前登录用户对该公开作品的点赞关系并返回更新后的真实 `likeCount` 读模型,前端不伪造点赞增长。
5. 统计区固定四项:
- 游玩:`playCount`,显示为“数字 + 次”,单位放在数字后方。
- 改造:`remixCount`,显示为“数字 + 次”,单位放在数字后方。
- 点赞:`likeCount`,显示为“数字 + 赞”,单位放在数字后方。
- 日期:优先展示 `updatedAt`,缺失时回退 `publishedAt`;前端只负责格式化显示,必须兼容后端当前 `seconds.microsZ` 与 ISO 字符串两种真实时间文本,显示为完整 `YYYY-MM-DD`,使用更小字号并保持单行不换行。
- 四项统计需要使用浅色图标底强化识别,但不得追加规则说明类文案。
6. 简介区:展示玩法标签和作品简介;不追加说明类文案。
7. 底部动作:左侧按钮为“作品改造”,右侧主按钮为“启动”;两个按钮必须位于同一行,点击“启动”后进入对应玩法运行态并记录游玩次数。
- 未登录用户可进入并浏览作品详情页,但点击“作品改造”和“启动”都必须先弹出登录入口面板;登录成功后自动继续刚才点击的动作,不直接发起 Remix、启动 run 或本地运行态。
8. 页面配色必须跟随平台明暗主题变量;亮色主题使用平台浅色底、深色文字和主按钮渐变,暗色主题使用平台暗色底、亮色文字和对应主按钮渐变,不在详情页写死独立黑色皮肤。
9. 字号规范跟随平台页面既有节奏:标题/主按钮使用 `1rem` 级别,作品名使用卡片标题同级 `1rem`,辅助信息与简介使用 `0.8125rem` / `0.875rem`,标签与统计标签使用 `0.75rem`,避免在详情页使用随视口放大的独立大字号。
## 3. 数据真相源
### 3.1 RPG 作品
1. `custom_world_profile` 增加 `play_count``remix_count``like_count`
2. `custom_world_gallery_entry` 同步这三项统计,作为公开详情和首页卡片读模型。
3. `record_custom_world_profile_play` 负责在公开作品启动前递增 `play_count`,只更新已发布且未删除作品。
4. `record_custom_world_profile_like` 负责记录当前用户对公开作品的点赞;同一用户对同一公开作品只计入一次,首次点赞时递增 `like_count` 并同步刷新 `custom_world_gallery_entry`
5. `remix_custom_world_profile` 在同一事务内:
- 校验源作品已发布、未删除。
- 递增源作品 `remix_count` 并刷新源作品 gallery。
- 复制源 profile payload 为当前用户草稿,清空公开编号、发布时间与统计。
- 返回新草稿 profile供前端进入草稿详情页。
### 3.2 拼图作品
1. `puzzle_work_profile` 保留既有 `play_count`,新增 `remix_count``like_count`
2. `start_puzzle_run` 继续作为游玩次数递增入口。
3. `record_puzzle_work_like` 负责记录当前用户对公开拼图作品的点赞;同一用户对同一公开作品只计入一次,首次点赞时递增 `like_count`
4. `remix_puzzle_work` 在同一事务内:
- 校验源 profile 为已发布作品。
- 递增源作品 `remix_count`
- 新建当前用户拼图 Agent session并把源作品锚点、封面、简介复制为草稿。
- 新建当前用户草稿 profile统计归零返回新草稿 session 与 profile。
4. API facade 解析拼图 `item_json` / `items_json` 时必须兼容历史公开作品缺失 `play_count``remix_count``like_count` 的 JSON缺失值统一按 `0` 处理;新写入数据仍必须写全统计字段。
### 3.3 大鱼吃小鱼作品
1. `big_fish_creation_session` 现有 `play_count` 继续作为游玩统计,新增 `remix_count``like_count``published_at`
2. `publish_big_fish_game` 写入 `published_at``updated_at`,公开列表和详情优先用 `updated_at` 展示最近更新。
3. `record_big_fish_play` 继续作为游玩次数递增入口。
4. `record_big_fish_like` 负责记录当前用户对公开大鱼作品的点赞;同一用户对同一公开作品只计入一次,首次点赞时递增 `like_count`
5. `remix_big_fish_work` 在同一事务内:
- 校验源 session 为已发布作品。
- 递增源作品 `remix_count`
- 新建当前用户创作 session复制锚点、草稿和资源槽阶段回到可编辑草稿态。
- 新 session 的统计归零,返回新草稿 session。
## 4. API 与前端接入
1. 三类公开作品摘要统一返回:`playCount``remixCount``likeCount``publishedAt``updatedAt`
- 作者头像不固化到作品读模型;详情页按 `authorPublicUserCode``ownerUserId` 读取公开用户摘要中的 `avatarUrl`,确保头像跟随账号资料更新。
2. Remix API
- RPG`POST /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/remix`
- 拼图:`POST /api/runtime/puzzle/gallery/{profile_id}/remix`
- 大鱼:`POST /api/runtime/big-fish/gallery/{session_id}/remix`
3. 点赞 API
- RPG`POST /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/like`
- 拼图:`POST /api/runtime/puzzle/gallery/{profile_id}/like`
- 大鱼:`POST /api/runtime/big-fish/gallery/{session_id}/like`
- 三个接口都必须走登录态鉴权,后端使用当前登录用户身份写入点赞关系;重复点击返回当前最新读模型,不重复增加 `likeCount`
4. 前端统一详情页只消费读模型字段,不自行派生统计。
5. 首页卡片点击只设置统一详情状态;启动、点赞与 Remix 只能在详情页触发。
6. Remix 成功后的跳转:
- RPG进入复制出的草稿详情。
- 拼图:进入复制出的拼图结果页草稿。
- 大鱼:进入复制出的大鱼结果页草稿。
7. 拼图作品详情页启动时复用当前详情页已经展示的公开作品读模型,直接调用 `POST /api/runtime/puzzle/runs` 记录游玩并进入运行态;不得在启动前额外依赖 `GET /api/runtime/puzzle/gallery/{profile_id}`,避免开发代理或详情读取短断点阻塞启动链路。
8. 本地开发时 `localhost:3000` 是 Vite 前端端口,`/api/**` 默认代理到 Rust `api-server:3100`;若 3100 未监听,点击启动、点赞或作品改造会在浏览器显示 `/api/... 500`,此时真实断点是 Rust 后端未启动,不允许用前端假数据替代后端事务。
## 5. 验收点
1. 三类作品从首页点击均先进入统一作品详情页。
2. 详情页无评价、论坛 Tab无开发者的话模块。
3. 四项统计在前端、共享契约、API facade、SpacetimeDB 表之间字段一致。
4. Remix 后原作品改造次数增加,新草稿归当前用户所有,且不会继承源作品统计。
5. 点赞公开作品会走对应后端记录入口,首次点赞后刷新仍能看到递增后的点赞次数,重复点赞不会继续增加。
6. 启动公开作品会走对应后端记录入口,刷新后仍能看到递增后的游玩次数。
7. 未登录进入作品详情页后,点击“作品改造”和“启动”只打开登录入口面板;登录成功后恢复对应动作,未登录期间不会创建 Remix 草稿、开始拼图 run、记录 RPG 游玩或启动大鱼本地运行态。
8. 移动端首页“推荐”和“今日游戏”列表滚动时,仅中心卡片自动轮播多封面;旧中心卡离开后回到首张封面,新的中心卡接续轮播。
9. 修改后运行编码检查、SpacetimeDB 绑定生成、Rust 检查和必要前端测试。

View File

@@ -29,6 +29,14 @@
网格规模仍可作为运行时内部状态存在,但不默认写在 UI 顶栏中。
### 1.1 2026-04-30 顶栏与底部工具补充
1. 顶栏作者信息不再只显示一行作者名,必须展示为作者头像与昵称组合;当前运行态只提供昵称时,用昵称首字生成圆形占位头像。
2. 倒计时组件提升为顶栏中的强信息,字号、内边距和图标尺寸都需要明显大于作者昵称与关卡序号。
3. 底部只保留 `提示 / 原图 / 冻结` 三个功能按钮,并整体居中展示;三个按钮触控面积和图标字号都需要放大。
4. 底部不再展示“等待下一关候选”这类状态占位。通关后在三个道具按钮上方固定展示“下一关”按钮,展示条件只依赖当前关卡已通关,不依赖 `recommendedNextProfileId` 是否已有值。
5. 点击底部“下一关”按钮继续调用运行时壳层已有 `onAdvanceNextLevel` 事件;正式 run 由后端 `next-level` 选择候选,本地 run 由 `local-next-level` 生成或接续下一关,前端不在按钮层自行决定下一关来源。
### 2. 拼图块显示规则
运行时单块右下角编号全部移除。

View File

@@ -4,12 +4,14 @@
## 文档列表
- [CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md](./CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md):自定义世界里创作者输入与 AI 分工边界设计。
- [CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md](./CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md):自定义世界里百梦主输入与 AI 分工边界设计。
- [CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md):自定义世界创作里“手填锚点 / AI 可改初稿 / 系统托管层”的平衡设计。
- [CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md):纯 Agent 式创作工具与结构化工作台方案的优缺点对比,以及转型设计。
- [CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md):把自定义世界从武侠/仙侠模板依赖迁到跨题材通用设定层的优化设计。
- [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。
- [MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md](./MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md):移动端创作页新建作品模块最多占用首屏约 1/3 高度的紧凑布局设计。
- [MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md](./MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md):移动端创作页作品列表至少 2 列的紧凑布局设计。
- [PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md](./PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md):平台首页移动端参考图式信息流、双端公开作品卡 16:9 封面结构与点赞数读模型设计。
- [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。
- [PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md](./PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md):平台入口暂时隐藏大鱼吃小鱼创作卡片,但保留现有玩法链路。
- [UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md](./UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md):统一平台风与 RPG 像素风模态窗口外壳、交互边界和迁移顺序。
@@ -27,8 +29,8 @@
- 做物品、Build、锻造相关需求时先看前两份。
- 做 RPG 全剧情规划、主支线矩阵、角色线、场景章节与剧情交付模板时,先看新增的全剧情策划流程。
- 做自定义世界创作工作台、创作者输入边界、AI 分工设计时,先看第一份。
- 做“哪些内容必须让创作者手填、哪些适合 AI 先生成再改、哪些必须系统托管”这类分层设计时,优先看新增的输入平衡设计稿。
- 做自定义世界创作工作台、百梦主输入边界、AI 分工设计时,先看第一份。
- 做“哪些内容必须让百梦主手填、哪些适合 AI 先生成再改、哪些必须系统托管”这类分层设计时,优先看新增的输入平衡设计稿。
- 做“是否应该转成纯 Agent 式创作工具、转了之后前后台各该怎么收口”这类产品方向评估时,优先看新增的纯 Agent 对比与转型设计稿。
- 做自定义世界去模板依赖、跨题材泛化、兼容迁移设计时,优先看新增的去模板化优化设计稿。
- 做“模板依赖如何真正变成自定义世界自有设定层”的具体迁移方案时,优先看新增的自有设定层优化方案。

View File

@@ -11,14 +11,17 @@
1. 负好感或敌对 NPC 进入聊天后,不再设置固定 5 回合上限。
2. 负好感或敌对 NPC 每轮回复后,模型必须判断本轮是否结束聊天。
3. 敌对 NPC 判定时应偏向随时结束聊天并进入对峙但必须结合玩家刚说的话、NPC 性格、当前剧情压力和对话历史。
4. 好感度大于等于 0 且非敌对 NPC 不启用模型终止判定,玩家可一直聊天
5. 模型判定终止后,聊天面板不再继续提供聊天输入,只显示“继续”按钮,点击后沿用原流程继续生成冒险选项
6. 点击“退出聊天”不再直接收起聊天页,也不立即进入剧情推理;它会发送一条结束聊天的玩家输入,对方回复后同样只显示“继续”按钮
7. 正向 NPC 的退出聊天只是玩家主动收束,不代表模型强制中止,也不展示战斗/逃跑选项
8. 对负好感或敌对 NPC在聊天终止后的后续流程仍沿用敌对出口继续推进后展示一个“战斗”选项以及按相邻场景和当前场景起点展开的多个逃跑选项。
9. 聊天候选中允许混入当前 NPC 可执行 function例如交易、送礼、请求帮助、招募、接任务、交任务、开战、离开等
10. Function 候选进入聊天上下文时只作为可触发动作,不在 UI 中展示说明类文本
11. “换一换”在聊天态可用,用于在不推进对话的情况下改排/轮换当前候选;它不调用后端,不改变聊天历史
4. 敌对 NPC 感知到玩家负面发言时,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,应倾向立即 `shouldEndChat=true`
5. 敌对 NPC 已聊天轮次超过 4 轮后,应倾向立即 `shouldEndChat=true`,避免敌对关系被拖成长时间闲聊
6. 敌对 NPC 最后一轮回复必须像战斗、驱逐或正面对峙前的狠话:短促、带压迫感、明确把局势推向行动前一刻,但不在对白中直接结算战斗
7. 好感度大于等于 0 且非敌对 NPC 不启用模型终止判定,玩家可一直聊天
8. 模型判定终止后,聊天面板不再继续提供聊天输入,只显示“继续”按钮,点击后沿用原流程继续生成冒险选项。
9. 点击“退出聊天”不再直接收起聊天页,也不立即进入剧情推理;它会发送一条结束聊天的玩家输入,对方回复后同样只显示“继续”按钮
10. 正向 NPC 的退出聊天只是玩家主动收束,不代表模型强制中止,也不展示战斗/逃跑选项
11. 对负好感或敌对 NPC在聊天终止后的后续流程仍沿用敌对出口继续推进后展示一个“战斗”选项以及按相邻场景和当前场景起点展开的多个逃跑选项
12. 聊天候选中允许混入当前 NPC 可执行 function例如交易、送礼、请求帮助、招募、接任务、交任务、开战、离开等。
13. Function 候选进入聊天上下文时只作为可触发动作,不在 UI 中展示说明类文本。
14. “换一换”在聊天态可用,用于在不推进对话的情况下改排/轮换当前候选;它不调用后端,不改变聊天历史。
## 3. 后端契约
@@ -43,12 +46,14 @@
1. 敌对聊天可随时中止NPC 更偏好结束谈判转入战斗或驱逐。
2. 终止不等于在回复正文里直接执行战斗,只需要用台词把对话收束到对峙、威胁、驱逐、最后通牒或行动前一刻。
3. 玩家主动退出聊天时NPC 回复要对这次收束作出回应,并留下自然的后续入口。
4. 若玩家本轮发言明显负面,或敌对聊天已进入第 5 轮及之后,回复 prompt 要提示 NPC 直接给出战斗前、驱逐前或正面对峙前的狠话。
建议 prompt 需要明确:
1. 常规聊天候选继续生成玩家台词。
2. Function 候选要根据提供的 function 列表,改写成玩家可直接点击的动作文本。
3. 不输出规则说明,不把 functionId 暴露给玩家。
4. 敌对聊天判断 `shouldEndChat` 时,负面发言和已聊天轮次超过 4 轮都应作为强收束信号;如果返回 `shouldEndChat=true``terminationReason` 使用 `hostile_breakoff`
## 5. 前端流程
@@ -81,8 +86,10 @@
1. 负好感主 NPC 不再出现固定 `turnLimit: 5`
2. 敌对 NPC 每轮请求会向后端传 `terminationMode: hostile_model`
3. 模型返回 `forceExit: true` 后,聊天输入消失,只显示继续按钮。
4. 好感度大于等于 0 的 NPC 聊天不传敌对中止模式
5. 点击退出聊天会新增玩家结束聊天气泡与 NPC 回复,而不是直接切走面板
6. 聊天态可看到并点击 function 候选,且“换一换”可改变候选顺序
7. 选项文字前出现中文 function 标签,且标签不改变原 actionText
8. 聊天结束后的“继续冒险”直接进入下一幕;最后一幕则展示多个相邻场景方向入口。
4. 玩家对敌对 NPC 说出挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界类发言时,模型应倾向返回 `shouldEndChat=true`NPC 最后一轮回复带战斗前狠话
5. 敌对 NPC 已聊天轮次超过 4 轮后,模型应倾向返回 `shouldEndChat=true`NPC 最后一轮回复带战斗前狠话
6. 好感度大于等于 0 的 NPC 聊天不传敌对中止模式
7. 点击退出聊天会新增玩家结束聊天气泡与 NPC 回复,而不是直接切走面板
8. 聊天态可看到并点击 function 候选,且“换一换”可改变候选顺序。
9. 选项文字前出现中文 function 标签,且标签不改变原 actionText。
10. 聊天结束后的“继续冒险”直接进入下一幕;最后一幕则展示多个相邻场景方向入口。

View File

@@ -207,4 +207,12 @@
---
## 18. 2026-04-30 资料兑换码弹窗响应式修正
- `RpgEntryHomeView.tsx` 的兑换码弹窗现在抽成同一份 `rewardCodeModal`,桌面与移动端分支都挂载,避免竖屏点击头像右侧“兑换码”后只更新状态但不显示窗口。
- `src/index.css` 已补齐 `platform-modal-backdrop``platform-recharge-modal``platform-profile-input``platform-primary-button``platform-modal-close` 与兑换结果提示样式;后续资料类轻量弹窗可以复用这组类接入平台主题背景。
- 兑换码窗口仍只保留输入框、兑换按钮和后端返回提示,不新增规则说明文案。
---
_文档目的交接给下一个 Agent 时,优先读本文件 + `UI_CODING_STANDARD.md`,再改 `uiAssets.ts` / `App.tsx` / `index.css`。_

View File

@@ -29,7 +29,7 @@
结论:
- 独立编辑器入口如果没有继续接入主流程,应及时物理删除,不要长期保留兼容壳
- 页签命名要贴近创作者语言,而不是内部实现命名
- 页签命名要贴近百梦主语言,而不是内部实现命名
### 2.2 NPC 视觉模块并入 NPC 编辑
@@ -144,7 +144,7 @@
经验:
- 创作者并不关心 “function” 这个技术词,更关心“这个选项会发生什么”
- 百梦主并不关心 “function” 这个技术词,更关心“这个选项会发生什么”
- 同类编辑器如果只给字段表单而没有模板起稿能力,复用效率会很低
### 2.8 选项行为预览升级到实机回放
@@ -217,7 +217,7 @@
- 预览面板要么都显示“实时状态”
- 要么都显示“同一个阶段的快照”
- 混用实时值和预测值会让创作者误判
- 混用实时值和预测值会让百梦主误判
## 4. 这类项目里沉淀下来的方法论
@@ -245,7 +245,7 @@
- 不是所有字段都应该在所有行为类型下开放
- 如果某类行为最终不会直接读取某个字段,就应该禁用或弱化它
- 否则创作者会错误地以为改动无效是 bug
- 否则百梦主会错误地以为改动无效是 bug
### 4.4 模板比空白表单更重要

View File

@@ -25,7 +25,7 @@
3. 历史已发布作品必须能自动补齐 gallery 投影。
- 公开列表读取 `list_custom_world_gallery_entries` 前,会扫描 `custom_world_profile` 中已发布且未删除的 profile。
- 若已发布 profile 缺少 `custom_world_gallery_entry`,或缺少公开作品码 / 作者叙世号,会先补齐公开字段并同步 gallery 投影。
- 若已发布 profile 缺少 `custom_world_gallery_entry`,或缺少公开作品码 / 作者百梦号,会先补齐公开字段并同步 gallery 投影。
- 这样旧版本发布成功但未落入广场读模型的作品,在下一次首页 / 分类页读取公开列表时会自动出现。
## 经验

View File

@@ -394,7 +394,7 @@ MVP 阶段不需要单独设置密码。
落地规则:
- 入参只允许 `phone``password`,不支持邮箱、用户名或叙世号。
- 入参只允许 `phone``password`,不支持邮箱、用户名或百梦号。
- 手机号不存在时,不创建账号,返回统一的登录失败。
- 手机号存在但账号未设置过密码时,不允许密码登录。
- 首次设置密码只能在已登录账号中心内完成;用户必须先通过手机号验证码或已绑定手机号的微信账号进入已登录态。
@@ -734,7 +734,7 @@ MVP 阶段建议至少提供一个轻量账号中心,包含:
约束:
- 不支持邮箱、用户名或叙世号。
- 不支持邮箱、用户名或百梦号。
- 不承担注册能力。
- 只有已存在、已验证手机号、且 `passwordLoginEnabled=true` 的账号可以登录。

View File

@@ -0,0 +1,169 @@
# 后台管理独立前端工程 PRD
日期:`2026-04-30`
## 1. 目标
本 PRD 定义当前项目的后台管理独立前端工程 v1。目标不是重做一套后端也不是把玩家端前端改造成后台而是在 monorepo 内新增一个独立管理端应用,正式承接已经从 `api-server` 内嵌页面移出的后台 UI。
首版目标:
1. 后台 UI 独立落在 `apps/admin-web`,不再写入 Rust 源码字符串。
2. 管理数据、业务规则、权限校验和写操作继续统一走 `server-rs/crates/api-server`
3. v1 只接管已有管理能力:管理员登录、当前管理员信息、服务/数据库概览、受控 API 调试、兑换码管理、注册邀请码管理。
4. 保持管理端清爽、可扫读、移动端可用,不在界面堆大段规则说明。
5. 发布包内由 Web 网关把独立后台前端挂到同域 `/admin/`Rust `api-server` 自身仍不恢复旧的 `GET /admin` 内嵌页面。
## 2. 用户与使用场景
### 2.1 目标用户
1. 项目开发者:用于本地或测试环境快速查看 `api-server` 与 SpacetimeDB 连接状态。
2. 运维/运营人员:用于创建、更新、停用兑换码,预置注册邀请码,以及确认后台接口是否可用。
3. 后续后台功能开发者:以本工程作为后续用户、作品、资产、审核等管理模块的承载壳。
### 2.2 首版高频场景
1. 管理员打开后台登录页,输入环境变量配置的管理员用户名和密码。
2. 登录后进入总览页确认当前服务监听、JWT issuer、SpacetimeDB server/database、schema 表数量和关键表统计。
3. 在 API 调试页对当前 `api-server` 的同源相对路径发起受控请求,排查接口状态。
4. 在兑换码管理页创建或更新公共码、唯一码、私有码,并在需要时停用兑换码。
5. 在邀请码管理页创建或更新注册链路使用的邀请码与 metadata。
## 3. 功能范围
### 3.1 包含
1. 独立前端工程骨架:`apps/admin-web`
2. 登录页:
- 调用 `POST /admin/api/login`
- 登录成功后保存管理员 Bearer token。
- 登录失败直接展示后端返回的错误消息。
3. 管理端 Shell
- 顶部显示当前管理员、登录状态和退出按钮。
- 侧边或底部导航包含“总览”“API 调试”“兑换码”“邀请码”。
- 移动端导航改为底部或顶部紧凑标签。
4. 总览页:
- 调用 `GET /admin/api/me` 恢复管理员会话。
- 调用 `GET /admin/api/overview` 展示服务和数据库概览。
- 表统计允许部分失败,并直接展示失败项。
5. API 调试页:
- 调用 `POST /admin/api/debug/http`
- 只允许填写同源相对路径,不提供绝对 URL 输入暗示。
- 调试结果在独立结果面板展示状态码、响应头、响应文本和 JSON 预览。
6. 兑换码管理页:
- 调用 `POST /admin/api/profile/redeem-codes` 创建/更新兑换码。
- 调用 `POST /admin/api/profile/redeem-codes/disable` 停用兑换码。
- 支持 `public``unique``private` 三种模式。
- 私有码支持输入内部 `userId` 和公开百梦号,提交给后端统一解析。
7. 邀请码管理页:
- 调用 `POST /admin/api/profile/invite-codes` 创建/更新注册邀请码。
- 支持输入 JSON 对象 metadata提交前做基础 JSON 对象校验,最终校验以服务端为准。
### 3.2 不包含
1. 不新建后台专用后端服务、BFF、Express 服务或 PostgreSQL 数据面。
2. 不让管理端直接连接 SpacetimeDB TypeScript SDK。
3. 不新增 SpacetimeDB 表结构。
4. 不实现完整用户管理、作品审核、资产审核、充值订单后台。
5. 不实现多角色权限体系、管理员 refresh session、多端会话管理。
6. 不保留 Rust `api-server``GET /admin` 同源内嵌页面作为正式后台入口;部署态 `/admin/` 只允许由独立后台前端静态产物承接。
## 4. 页面与交互要求
### 4.1 登录页
登录页只保留必要输入和状态反馈:
1. 用户名输入框。
2. 密码输入框。
3. 登录按钮。
4. 登录失败状态。
5. 后台未启用状态。
后台未启用时,前端根据 `POST /admin/api/login``503` 响应展示不可登录状态,不额外引导用户在页面配置环境变量。
### 4.2 总览页
总览页必须能快速扫读:
1. 服务状态监听地址、端口、JWT issuer。
2. SpacetimeDB 配置server URL、database。
3. 数据库元信息database identity、owner identity、host type。
4. 表清单与表统计:展示成功计数和失败原因。
总览页不展示任意 SQL 输入框。
### 4.3 API 调试页
API 调试页是受控接口调试台,不是通用代理工具:
1. method 使用选择控件。
2. path 只接受 `/xxx` 形式。
3. headers 使用结构化键值编辑。
4. body 使用文本框。
5. 结果展示在固定结果面板,不在按钮下方临时插入内容。
### 4.4 兑换码管理页
兑换码管理页首版采用一个创建/更新表单和一个停用表单:
1. code提交前可在前端 trim但最终标准化以服务端为准。
2. mode`public``unique``private`
3. rewardPoints必须为正整数最终校验以服务端为准。
4. maxUses必须为正整数最终校验以服务端为准。
5. enabled创建/更新时可切换。
6. allowedUserIds私有码允许的内部用户 ID 列表。
7. allowedPublicUserCodes私有码允许的公开百梦号列表。
提交成功后展示后端返回的兑换码记录;失败时展示后端错误消息。
### 4.5 邀请码管理页
邀请码管理页首版采用一个创建/更新表单和一个结果面板:
1. inviteCode提交前可在前端 trim最终标准化以服务端为准。
2. metadata必须是 JSON 对象;空值按 `{}` 提交。
3. 提交成功后展示后端返回的 userId、inviteCode、createdAt、updatedAt 和 metadata。
4. 失败时展示后端错误消息。
## 5. 数据与权限边界
1. 管理端所有请求必须经过 `/admin/api/*`
2. 管理端持有的管理员 token 只用于后台接口,不复用玩家登录 token。
3. 普通玩家 token 访问管理接口应被后端拒绝。
4. 管理端不持久化密码。
5. token 首版可保存在 localStorage退出登录时立即清除。
6. SpacetimeDB 表、reducer、procedure、迁移和权限判断均不由管理端直接处理。
## 6. 验收标准
1. `apps/admin-web` 可以独立启动和构建。
2. 登录页可完成管理员登录,错误和未启用状态展示清楚。
3. 登录后刷新页面可通过 `GET /admin/api/me` 恢复会话。
4. 总览页可展示 `GET /admin/api/overview` 的服务与数据库数据。
5. API 调试页可通过后端调试接口访问 `/healthz`
6. 兑换码管理页可创建/更新、停用兑换码,并展示返回记录。
7. 邀请码管理页可创建/更新注册邀请码,并展示返回记录。
8. 直连 Rust `api-server``GET /admin` 保持 404不恢复旧内嵌页面通过发布包 Web 网关访问 `/admin/` 时返回独立后台前端。
9. `npm run check:encoding` 通过。
## 7. 首版任务拆解
首版编码按以下模块收口,避免独立后台工程继续扩散成未定义功能:
1. 工程骨架:创建 `apps/admin-web`,补齐 Vite、TypeScript、React 入口和独立构建脚本。
2. API client只封装 `/admin/api/*`,统一处理 Bearer token、统一 envelope 和后端错误消息。
3. 登录与会话:实现登录、启动恢复、`401` 清理、退出登录四个状态流。
4. 页面 Shell实现桌面侧栏/顶部栏与移动端紧凑导航,不在未登录状态渲染后台导航。
5. 总览页:展示 `service``database``tableStats``fetchErrors`,部分失败不阻断整页。
6. API 调试页:实现 method、path、headers、body 输入和独立结果面板。
7. 兑换码页:实现创建/更新、停用、三种 mode 切换和私有码用户列表输入。
8. 邀请码页:实现注册邀请码创建/更新、metadata JSON 对象输入和最近记录展示。
9. 验证:完成后台工程 typecheck/build、根工程编码检查以及 `/admin` 仍为 404 的后端验证。
## 8. 交互修正记录
### 8.1 兑换码记录页签保持
兑换码管理页的“记录”面板展示最近一次创建、更新或停用接口返回的兑换码记录。该记录在管理端会话内切换“总览”“API 调试”“兑换码”页签时必须保留,避免运营人员切页核对信息后丢失刚返回的结果。退出登录、登录新会话或刷新浏览器页面时可以清空该前端会话态;持久化查询能力后续由新的后端查询接口承接,不在首版用前端伪造历史列表。

View File

@@ -31,7 +31,7 @@
大鱼吃小鱼玩法是一个 `Agent-First` 的轻量实时成长玩法创作链:
**创作者先与 Agent 共创“进化母题、等级阶梯、生态风格与场地气质”,系统再自动编译出一个竖屏全屏、摇杆移动、吞噬收编、三合一进化、持续刷怪、升到最高级即通关的可运行玩法作品。**
**百梦主先与 Agent 共创“进化母题、等级阶梯、生态风格与场地气质”,系统再自动编译出一个竖屏全屏、摇杆移动、吞噬收编、三合一进化、持续刷怪、升到最高级即通关的可运行玩法作品。**
---
@@ -115,26 +115,26 @@
`Agent-First 大鱼吃小鱼玩法创作工具`
玩法运行态对外展示名可由创作者自定义,不强绑平台内部域名。
玩法运行态对外展示名可由百梦主自定义,不强绑平台内部域名。
## 5.2 目标用户
目标用户主要是 3 类:
1.创作者
1.百梦主
- 想快速做一个可玩的成长吞噬小游戏,但不懂完整关卡编辑器
2. 视觉驱动型创作者
2. 视觉驱动型百梦主
- 更关心“每级长什么样、动作怎么样、背景氛围如何”
3. 玩法原型创作者
3. 玩法原型百梦主
- 想快速验证一套吞噬成长节奏、等级曲线和场地压迫感
## 5.3 成功标准
本期上线后,至少要满足下面这些结果:
1. 创作者可在 `5~12` 分钟内通过 Agent 聊天形成一版可用锚点草稿。
1. 百梦主可在 `5~12` 分钟内通过 Agent 聊天形成一版可用锚点草稿。
2. 系统默认能编译出 `8` 级实体阶梯的初版玩法草稿。
3. 每一级实体都能在结果页单独生成和重生成主图。
4. 每一级实体都能在结果页单独生成和重生成动作。
@@ -179,9 +179,9 @@
Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事:
1.创作者明确高杠杆锚点
2.创作者把模糊灵感总结成可编译结构
3.创作者收束出第一版等级阶梯与视觉方向
1.百梦主明确高杠杆锚点
2.百梦主把模糊灵感总结成可编译结构
3.百梦主收束出第一版等级阶梯与视觉方向
## 7.2 前台交互原则
@@ -222,7 +222,7 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事:
3. `成长阶梯`
- 这一玩法一共大致有几级,以及每一级如何逐步升级、变大、变强、变异
- 最高级终局形态也并入这一锚点统一确定
-创作者没有明确总层数,本期默认按 `8` 级编译,可配置范围固定为 `6~12`
-百梦主没有明确总层数,本期默认按 `8` 级编译,可配置范围固定为 `6~12`
4. `风险节奏`
- 玩家周围应该更偏压迫、平衡还是偏爽快
@@ -235,7 +235,7 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事:
2. `等级总层数` 并入 `成长阶梯`
3. `升级轮廓` 并入 `成长阶梯`
4. `终局形态` 并入 `成长阶梯`
5. `开局成长方式` 改为系统固定规则,不再作为创作者锚点
5. `开局成长方式` 改为系统固定规则,不再作为百梦主锚点
后续 Agent 追问时,不再把这些内容拆成独立必答题。
@@ -302,7 +302,7 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事:
## 9.1 默认草稿规模
创作者没有特别指定时,第一版玩法草稿必须默认编译为:
百梦主没有特别指定时,第一版玩法草稿必须默认编译为:
1. `8` 级实体阶梯
2. `1` 张活动区域背景图

View File

@@ -680,7 +680,7 @@ assistant 回复应包含:
1. 对 seedText / 用户消息的简要复述
2. 当前仍缺哪些世界锚点
3. 建议创作者下一步回答什么
3. 建议百梦主下一步回答什么
#### 用户后续消息

View File

@@ -24,7 +24,7 @@
那么第二阶段的目标就是:
**让 Agent 会话真正开始理解创作者输入,并把自然语言聊天沉淀成结构化创作锚点。**
**让 Agent 会话真正开始理解百梦主输入,并把自然语言聊天沉淀成结构化创作锚点。**
一句话定义:

View File

@@ -26,7 +26,7 @@
那么第四阶段的目标就是:
**让创作者直接修改这版草稿设定,并且继续用 AI 为这版草稿扩出新的角色和场景。**
**让百梦主直接修改这版草稿设定,并且继续用 AI 为这版草稿扩出新的角色和场景。**
一句话定义:
@@ -100,7 +100,7 @@
一句话目标:
**让第四阶段结束时,创作者第一次能像在真正做作品一样修改草稿、继续长出新对象。**
**让第四阶段结束时,百梦主第一次能像在真正做作品一样修改草稿、继续长出新对象。**
---

View File

@@ -200,7 +200,7 @@
1. 主线关键角色
2. 可扮演角色
3. 创作者重点想看的角色
3. 百梦主重点想看的角色
## 7.2 入口位置

View File

@@ -42,21 +42,21 @@
## 1.2 一句话定义
创作者通过与一个懂 RPG 剧情策划方法的 Agent 对话,逐步完成世界锚点收集、关键对象塑造、剧情骨架搭建和长尾内容展开;同时由 Express 后端持续维护结构化世界状态、锁定边界、局部重生成和质量检查。
百梦主通过与一个懂 RPG 剧情策划方法的 Agent 对话,逐步完成世界锚点收集、关键对象塑造、剧情骨架搭建和长尾内容展开;同时由 Express 后端持续维护结构化世界状态、锁定边界、局部重生成和质量检查。
## 1.3 目标用户
目标用户分三类:
1.创作者
1.百梦主
- 有世界灵感,但不擅长结构化填表
2. 中度创作者
2. 中度百梦主
- 愿意精修角色、地点、主线第一幕,但不想维护大量底层字段
3. 重度创作者
3. 重度百梦主
- 需要局部重生成、锁定、版本化和导出世界圣经
## 1.4 产品成功标准
@@ -76,7 +76,7 @@
1. 不把整套系统做成纯聊天黑箱。
2. 不让前端继续承担锁定合并、重生成裁决、结构编译等核心逻辑。
3. 不要求创作者直接编辑 `ThemePack / WorldStoryGraph / VisibilitySlice / ThreadContract` 等运行时结构。
3. 不要求百梦主直接编辑 `ThemePack / WorldStoryGraph / VisibilitySlice / ThreadContract` 等运行时结构。
4. 不把长项目世界管理完全交给一条无限增长的聊天记录。
5. 不再保留“生成完直接回世界列表并自动保存”的旧流程。
6. 不允许角色主图、角色动作、场景背景图继续停留在临时候选状态后直接发布世界。
@@ -151,7 +151,7 @@
1. `src/services/customWorldCreatorIntent.ts`
- 已有创作者意图、锚点包、锁定状态的基础结构
- 已有百梦主意图、锚点包、锁定状态的基础结构
2. `src/types/customWorld.ts`
@@ -228,7 +228,7 @@
后台必须持续维护:
1. 创作者意图
1. 百梦主意图
2. 锁定状态
3. 世界底稿快照
4. 可编辑草稿对象列表
@@ -271,11 +271,11 @@
-> 打开 Agent 创作入口
-> Agent 收集最小锚点
-> Agent 输出首轮世界底稿
-> 创作者锁定/修改关键内容
-> 百梦主锁定/修改关键内容
-> Agent 局部生成关键角色/地点/主线第一幕
-> 进入角色与场景资产工坊,生成主形象 / 动作 / 背景图
-> Agent 扩展长尾内容
-> 创作者发布世界
-> 百梦主发布世界
-> 保存到世界库并进入世界
```
@@ -2077,4 +2077,4 @@ Agent 会话每次 operation 完成后自动保存 session snapshot。
这次新创作工具的正确方向,不是把现有工作台换成一个更大的聊天框,而是:
**让 Agent 成为创作者的主交互入口,让 Express 后端成为真正的世界状态管理者,让锁定、局部重生成、摘要、质量护栏和发布链把整个创作过程牢牢收住。**
**让 Agent 成为百梦主的主交互入口,让 Express 后端成为真正的世界状态管理者,让锁定、局部重生成、摘要、质量护栏和发布链把整个创作过程牢牢收住。**

View File

@@ -37,15 +37,15 @@
## 1.3 目标用户
目标用户仍然是当前自定义世界创作工具的三类创作者,但本流程更偏向解决其中两类人的起步问题:
目标用户仍然是当前自定义世界创作工具的三类百梦主,但本流程更偏向解决其中两类人的起步问题:
1.创作者
1.百梦主
- 有模糊灵感,但不知道先想什么
2. 中度创作者
2. 中度百梦主
- 有一些设定点子,但缺少把设定收束成可运行剧情骨架的方法
重度创作者也可使用本流程,但他们更关心的是:
重度百梦主也可使用本流程,但他们更关心的是:
- Agent 是否会少问废话
- 摘要是否准确
@@ -1190,7 +1190,7 @@ Agent 不应回复成八问表:
## 13.2 后续可编辑范围
进入世界底稿阶段后,创作者默认优先精修:
进入世界底稿阶段后,百梦主默认优先精修:
1. 关键角色
2. 核心冲突与线程

View File

@@ -8,11 +8,11 @@
目标不是推翻当前已经存在的多阶段生成链,而是解决下面这个核心错位:
**当前仓库已经开始把世界生成拆成 `framework -> themePack -> storyGraph -> role outline -> dossier -> narrativeProfile` 的分阶段 AI 编译流程,但创作者入口仍然是“一段大文本”,结果页又把大量低杠杆字段重新扔回给创作者人工兜底。**
**当前仓库已经开始把世界生成拆成 `framework -> themePack -> storyGraph -> role outline -> dossier -> narrativeProfile` 的分阶段 AI 编译流程,但百梦主入口仍然是“一段大文本”,结果页又把大量低杠杆字段重新扔回给百梦主人工兜底。**
一句话定义本次优化:
**让创作者先定义世界灵魂锚点,再让 AI / 系统围绕锚点分层生成、分层展开、分层可控地完成长尾内容。**
**让百梦主先定义世界灵魂锚点,再让 AI / 系统围绕锚点分层生成、分层展开、分层可控地完成长尾内容。**
## 1. 当前流程现状
@@ -64,7 +64,7 @@
## 1.3 当前流程的核心问题
## 1.3.1 创作者入口过于粗糙
## 1.3.1 百梦主入口过于粗糙
当前创建入口只有一块大文本输入框。
@@ -72,23 +72,23 @@
1. 不会写长描述的用户很难开局。
2. 愿意精细创作的用户没有结构化落点。
3. 系统无法明确分辨“哪些是创作者真正想锁定的锚点,哪些只是随口补充的描述”。
3. 系统无法明确分辨“哪些是百梦主真正想锁定的锚点,哪些只是随口补充的描述”。
结果就是:
**输入端自由但信息信号不稳定AI 虽然能生成很多内容,却不一定生成的是创作者真正关心的内容。**
**输入端自由但信息信号不稳定AI 虽然能生成很多内容,却不一定生成的是百梦主真正关心的内容。**
## 1.3.2 创作者与 AI 的职责发生倒置
## 1.3.2 百梦主与 AI 的职责发生倒置
当前流程实际上是:
- 创作者先写一段泛化设定
- 百梦主先写一段泛化设定
- AI 再把整个世界铺满
- 创作者最后回到结果页,人工修改大量角色、章节、技能、初始物品、场景连接等细节
- 百梦主最后回到结果页,人工修改大量角色、章节、技能、初始物品、场景连接等细节
这与“低创作门槛、高创作自由度”的目标相反。
因为真正应该由创作者控制的,是:
因为真正应该由百梦主控制的,是:
- 世界核心命题
- 主题与气质
@@ -98,7 +98,7 @@
- 关键地点
- 标志性物件 / 怪物 / 禁忌
而不是让创作者在结果页里逐个补:
而不是让百梦主在结果页里逐个补:
- `backstoryReveal.chapters`
- `skills`
@@ -117,13 +117,13 @@
问题不在数量本身,而在于系统并没有明确区分:
1. 哪些是创作者应重点塑造的关键对象
1. 哪些是百梦主应重点塑造的关键对象
2. 哪些只是 AI 应自动展开的长尾铺量
这会导致两个问题:
1. AI 在早期就花大量成本生成长尾内容,等待时间长。
2. 创作者在结果页里面对的是一整套“全部都生成了”的世界,而不是“先抓关键锚点,再决定是否继续铺开”。
2. 百梦主在结果页里面对的是一整套“全部都生成了”的世界,而不是“先抓关键锚点,再决定是否继续铺开”。
## 1.3.4 当前结果页暴露了过多低杠杆字段
@@ -134,7 +134,7 @@
- 场景 NPC 分配
- 场景连接网络
这对“专业创作者”当然有帮助,但对目标用户来说,容易把工具变成:
这对“专业百梦主”当然有帮助,但对目标用户来说,容易把工具变成:
**看起来自由度很高,实际上需要承担很多系统编辑工作。**
@@ -144,11 +144,11 @@
这意味着:
1. 创作者一旦修改过内容,就会担心被覆盖。
1. 百梦主一旦修改过内容,就会担心被覆盖。
2. 没有“锁定关键内容,只重生成长尾部分”的机制。
3. AI 无法真正成为创作搭档,只像一次性大批量生成器。
## 1.3.6 当前生成阶段是“模型视角”,不是“创作者视角”
## 1.3.6 当前生成阶段是“模型视角”,不是“百梦主视角”
当前生成页展示的是系统批次和阶段进度,这很好,但它主要回答的是:
@@ -156,7 +156,7 @@
没有回答的是:
- 创作者最关心的关键角色是否已经成型
- 百梦主最关心的关键角色是否已经成型
- 世界冲突是否已经稳定
- 当前这轮已经锁定了哪些核心创意
- 接下来生成的是关键锚点,还是长尾内容
@@ -170,19 +170,19 @@
这次优化要同时满足 6 个目标:
1. 降低输入门槛
- 不要求创作者一上来写长文,不要求理解系统字段。
- 不要求百梦主一上来写长文,不要求理解系统字段。
2. 提高高杠杆创作自由度
-创作者直接控制世界灵魂锚点,而不是低价值细节。
-百梦主直接控制世界灵魂锚点,而不是低价值细节。
3. 明确创作者与 AI 的职责边界
- 创作者负责“决定什么值得创作”AI 负责“把它展开并跑起来”。
3. 明确百梦主与 AI 的职责边界
- 百梦主负责“决定什么值得创作”AI 负责“把它展开并跑起来”。
4. 保留现有分阶段生成骨架
- 不推翻 `framework -> themePack -> storyGraph -> role/landmark` 的已有结构。
5. 引入锁定与局部重生成
-创作者能保住自己在乎的内容,只重做其余部分。
-百梦主能保住自己在乎的内容,只重做其余部分。
6. 把结果页从“数据总表”升级成“创作工作台”
- 让编辑界面按创作价值组织,而不是按底层对象堆字段。
@@ -192,11 +192,11 @@
优化后的自定义世界流程应该改为:
```text
创作者输入世界锚点
-> AI 编译创作者意图摘要
-> 创作者确认 / 锁定关键锚点
百梦主输入世界锚点
-> AI 编译百梦主意图摘要
-> 百梦主确认 / 锁定关键锚点
-> AI 先生成关键角色与关键地点
-> 创作者可局部修改 / 局部重生成
-> 百梦主可局部修改 / 局部重生成
-> AI 再展开长尾 NPC、长尾场景与运行时编译结构
-> 结果页以“锚点 / 关键对象 / 扩展内容 / 运行时摘要”方式组织
-> 保存并进入世界
@@ -204,7 +204,7 @@
一句话:
**先做创作决策,再做内容展开;先做关键对象,再做长尾铺量;先让创作者锁定灵魂,再让 AI 扩散世界。**
**先做创作决策,再做内容展开;先做关键对象,再做长尾铺量;先让百梦主锁定灵魂,再让 AI 扩散世界。**
## 4. 输入层优化方案
@@ -251,7 +251,7 @@
2. 卡片模式
- 用户直接按结构化方式输入世界锚点
两种模式最终都编译成统一的创作者意图对象。
两种模式最终都编译成统一的百梦主意图对象。
## 4.3 必填与选填要分开
@@ -272,7 +272,7 @@
- 标志性要素
- 禁止事项
这样既能保证世界最小成型,又不会把创作者门槛抬高。
这样既能保证世界最小成型,又不会把百梦主门槛抬高。
## 4.3.1 抽象统一“聊天补充设定”能力
@@ -307,11 +307,11 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
1. AI 不得在重生成时覆盖该内容
2. 长尾内容只能围绕它展开
3. 结果页里应明确显示其为“创作者锚点”
3. 结果页里应明确显示其为“百梦主锚点”
## 5. 生成链路优化方案
## 5.1 新增“创作者意图编译层”
## 5.1 新增“百梦主意图编译层”
在真正开始世界生成前,先新增一个轻量阶段:
@@ -324,19 +324,19 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
输出:
- 创作者意图摘要
- 百梦主意图摘要
- 世界锚点摘要
- 系统识别出的关键角色 / 冲突 / 地点 / 禁忌
这一步的作用不是生成世界,而是先回答:
1. 系统理解到的世界核心是什么
2. 哪些内容将被视为创作者强锚点
2. 哪些内容将被视为百梦主强锚点
3. 哪些内容将交给 AI 扩展
## 5.2 把当前生成链改成“关键先行、长尾后补”
当前 `generateCustomWorldProfile(...)` 的分阶段结构可以保留,但生成顺序需要更创作者化。
当前 `generateCustomWorldProfile(...)` 的分阶段结构可以保留,但生成顺序需要更百梦主化。
建议改成 5 层:
@@ -347,9 +347,9 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
- 世界框架
- ThemePack
- StoryGraph 的基础版
- 创作者锚点摘要
- 百梦主锚点摘要
这一层完成后,系统应能让创作者看到:
这一层完成后,系统应能让百梦主看到:
- 世界现在到底被理解成了什么
- 哪些冲突 / 势力 / 意象被识别出来了
@@ -362,11 +362,11 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
- 关键场景角色
- 关键地点
这一层优先围绕创作者明确输入的角色和地点,而不是先铺满全部数量。
这一层优先围绕百梦主明确输入的角色和地点,而不是先铺满全部数量。
### 第三层:创作者校对层
### 第三层:百梦主校对层
在继续展开长尾内容前,应允许创作者做一次轻量校对:
在继续展开长尾内容前,应允许百梦主做一次轻量校对:
- 确认关键角色是否对
- 确认关键地点是否对
@@ -408,7 +408,7 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
这样做的价值很高:
1. 降低首次等待焦虑
2.创作者更早介入关键对象校正
2.百梦主更早介入关键对象校正
3. 避免系统在创作方向还没稳定前,先铺满大量长尾内容
## 5.4 角色与场景生成要改成“锚点优先 + 长尾补位”
@@ -417,11 +417,11 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
优化后应改为:
1. 先生成创作者明确指定的关键角色 / 地点
1. 先生成百梦主明确指定的关键角色 / 地点
2. 再根据世界冲突自动补位缺失的角色原型和场景功能位
3. 最后再铺长尾
这样生成出来的世界会更像“围绕创作者意图长出来”,而不是“先生成了一个完整世界,再让创作者去认领”
这样生成出来的世界会更像“围绕百梦主意图长出来”,而不是“先生成了一个完整世界,再让百梦主去认领”
## 6. 结果页与编辑工作台优化方案
@@ -439,7 +439,7 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
优化后建议改成 4 层工作台:
1. 创作锚点
- 展示创作者输入和锁定内容
- 展示百梦主输入和锁定内容
2. 关键对象
- 关键角色、关键地点、关键冲突对象
@@ -448,11 +448,11 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
- AI 自动展开的长尾角色、长尾地点、补位内容
4. 世界编译摘要
- 展示世界线程、题材包、运行时摘要,但默认不要求创作者编辑
- 展示世界线程、题材包、运行时摘要,但默认不要求百梦主编辑
## 6.2 编辑界面应遵守“高价值字段前置,低价值字段折叠”
创作者默认暴露的应是:
百梦主默认暴露的应是:
- 角色一句话定位
- 角色表面面貌
@@ -507,7 +507,7 @@ RPG 创作工作台、拼图创作工作台、大鱼吃小鱼创作工作台都
## 7.1 新增 `CustomWorldCreatorIntent`
建议新增创作者输入的统一结构:
建议新增百梦主输入的统一结构:
```ts
interface CustomWorldCreatorIntent {
@@ -529,7 +529,7 @@ interface CustomWorldCreatorIntent {
作用:
- 把“创作者真正输入了什么”从最终 `CustomWorldProfile` 中分离出来
- 把“百梦主真正输入了什么”从最终 `CustomWorldProfile` 中分离出来
## 7.2 新增 `CustomWorldAnchorPack`
@@ -583,7 +583,7 @@ interface CustomWorldGenerationDraft {
作用:
- 让“创作者输入、AI 编译、结果编辑”成为连续工作流,而不是只有最终成品对象
- 让“百梦主输入、AI 编译、结果编辑”成为连续工作流,而不是只有最终成品对象
## 8. 与当前仓库的接入建议
@@ -597,7 +597,7 @@ interface CustomWorldGenerationDraft {
目标:
- 把单 textarea 升级为“快速模式 + 卡片模式”
- 新增创作者意图状态
- 新增百梦主意图状态
- 新增锁定和局部重生成入口
## 8.2 prompt 与生成服务层
@@ -623,7 +623,7 @@ interface CustomWorldGenerationDraft {
目标:
-`CustomWorldProfile` 增加创作者意图与锚点相关扩展字段
-`CustomWorldProfile` 增加百梦主意图与锚点相关扩展字段
- 保持旧档兼容
- 让现有 builder 能同时消费 `creatorIntent + anchorPack + profile seed`
@@ -647,28 +647,28 @@ interface CustomWorldGenerationDraft {
本次优化不做以下事情:
1. 不推翻当前自定义世界最终输出仍是 `CustomWorldProfile` 的兼容目标
2. 不把所有运行时结构都暴露给创作者直接编辑
3. 不要求创作者理解 `themePack / storyGraph / knowledgeFacts / threadContracts` 等系统结构
4. 不把复杂数值平衡、掉落预算、build 预算转移给创作者
2. 不把所有运行时结构都暴露给百梦主直接编辑
3. 不要求百梦主理解 `themePack / storyGraph / knowledgeFacts / threadContracts` 等系统结构
4. 不把复杂数值平衡、掉落预算、build 预算转移给百梦主
5. 不把“高自由度”理解成“所有字段都手工可改”
## 10. 验收标准
做到以下几点,才算这次优化真正成立:
1. 创作者可以不用写长文,只靠卡片输入也能完成自定义世界创建。
2. 系统会明确区分“创作者锚点”和“AI 自动展开内容”。
3. 创作者不再需要默认手改大量 `skills / initialItems / backstoryReveal / scene connections` 才能得到可用世界。
1. 百梦主可以不用写长文,只靠卡片输入也能完成自定义世界创建。
2. 系统会明确区分“百梦主锚点”和“AI 自动展开内容”。
3. 百梦主不再需要默认手改大量 `skills / initialItems / backstoryReveal / scene connections` 才能得到可用世界。
4. 结果页支持锁定关键角色、关键地点、关键冲突,并支持局部重生成。
5. 重新生成不再默认覆盖整个世界。
6. 当前 `framework -> themePack -> storyGraph -> role/landmark` 生成主链可以继续复用,而不是被废弃。
7. 结果页默认展示的是高创作价值对象,而不是系统级低层字段。
8. 长尾内容生成明显后置于关键对象生成,创作者能更早看到并修正关键对象。
8. 长尾内容生成明显后置于关键对象生成,百梦主能更早看到并修正关键对象。
9. 旧的自由文本输入模式仍然可用,但不再是唯一入口。
## 11. 推荐落地顺序
## 阶段 A先加创作者意图层
## 阶段 A先加百梦主意图层
先做:
@@ -678,7 +678,7 @@ interface CustomWorldGenerationDraft {
目标:
- 先把创作者输入从“单一大文本”升级成“可识别的创作锚点”
- 先把百梦主输入从“单一大文本”升级成“可识别的创作锚点”
## 阶段 B再加锚点包与锁定能力
@@ -721,4 +721,4 @@ interface CustomWorldGenerationDraft {
当前自定义世界流程最需要优化的,不是“让 AI 再多生成一点内容”,而是:
**把创作者从低价值字段编辑里解放出来,让创作者负责世界灵魂锚点,让 AI 负责围绕这些锚点分层生成、分层展开、分层可控地把世界长出来。**
**把百梦主从低价值字段编辑里解放出来,让百梦主负责世界灵魂锚点,让 AI 负责围绕这些锚点分层生成、分层展开、分层可控地把世界长出来。**

View File

@@ -0,0 +1,737 @@
# AI 原生抓大鹅 Match3D 玩法创作工具与玩法系统 PRD
更新时间:`2026-04-30`
## 0. 文档目的
这份 PRD 用于在当前平台内新增一条“抓大鹅”玩法模板链路,并把首版 demo 的产品边界、创作链路、运行规则和工程落点冻结到可以继续拆技术方案的程度。
本玩法对外展示名称为“抓大鹅”,子标题为“经典消除玩法”;开发代号为 `Match3D`。本文统一使用 `Match3D` 表示工程玩法域。
本次不是区分“抓大鹅”和 `Match 3D` 两套模板,也不是只做一个前端临时小游戏。它们的基础交互逻辑一致,后续差异只作为节奏和规则优化项继续迭代。
首版目标先抛出单局 demo边体验边打磨最终目标仍要与拼图玩法一样接入作品广场、他人作品游玩和后续关卡推荐。
---
## 1. 一句话定义
让百梦主通过 Agent 对话确认题材、需要消除次数和难度,系统编译出一个可试玩、可发布的单局抓大鹅玩法作品;玩家在 `10` 分钟倒计时内点击圆形空间中可见物品,把物品放入下方 `7` 格备选栏,每凑齐 `3` 个同物品 id 自动消除,最终清空圆形空间内全部物品即胜利。
---
## 2. 产品定位
## 2.1 模板名称
1. 对外模板名称:`抓大鹅`
2. 对外子标题:`经典消除玩法`
3. 开发代号:`Match3D`
4. 内部玩法域命名应独立于 RPG、拼图和大鱼吃小鱼不挂在旧 `customWorld``rpgWorld``puzzle``bigFish` 语义下。
## 2.2 首版目标
首版只做单局 demo 主链:
```text
平台创作入口
-> 选择“抓大鹅”
-> Agent 对话确认题材、需要消除次数、难度
-> 生成待发布结果页
-> 编辑作品基础信息
-> 发布前试玩
-> 发布作品
-> 玩家进入单局运行态
-> 胜利 / 失败结算
```
## 2.3 最终目标
后续完整链路需要补齐:
1. 发布后的作品进入首页、分类页和作品广场。
2. 玩家可游玩他人发布的抓大鹅作品。
3. 支持后续关卡推荐。
4. 记录成绩,并预留排行榜机制。
5. 支持已发布作品二次编辑。
---
## 3. 与现有平台链路的关系
## 3.1 复用什么
1. 复用平台创作中心入口。
2. 复用 Agent-first 创作体验。
3. 复用“创作会话 -> 结果页 -> 发布 -> 运行态”的平台主链。
4. 复用作品基础信息、标签、封面、发布接口和作品管理体验。
5. 复用现有 Rust / SpacetimeDB 后端基线。
## 3.2 不复用什么
1. 不复用 RPG 的世界、角色、章节、剧情推进结构。
2. 不复用拼图的网格切图、交换、合并块和下一关推荐算法。
3. 不复用大鱼吃小鱼的实时吞噬、实体等级和摇杆移动规则。
4. 不把 Match3D 运行规则写成前端本地真相源,但局内即时反馈效果由前端负责呈现。
5. 不使用 `server-node` 或 PostgreSQL 作为新增玩法后端。
## 3.3 独立玩法域要求
Match3D 必须形成独立玩法域,后续技术方案至少需要覆盖:
1. `module-match3d` 纯领域 crate。
2. `spacetime-module` 内的 Match3D 表与 procedure。
3. `shared-contracts` 内的 Match3D DTO。
4. `spacetime-client` 内的 Match3D 调用封装。
5. `api-server` 内的 Match3D facade 路由。
6. 前端 Match3D 创作、结果页、试玩和运行态组件。
---
## 4. 本次目标
首版 demo 必须满足:
1. 平台新增“抓大鹅”玩法创作入口。
2. 创建流程采用 Agent 对话收集关键配置。
3. Agent 必须在进入结果页前确认:
- 题材主题
- 需要消除次数
- 难度 `1~10`
4. 支持系统自动补全配置,用户确认后开始创造。
5. 题材阶段允许上传参考图片。
6. 结果页支持编辑游戏名称、标签、封面图等基础发布信息。
7. 发布前支持试玩,并允许随时停止和修改配置。
8. 发布不要求试玩通关。
9. 单局运行态使用 `10` 分钟倒计时。
10. 下方备选栏固定为 `7` 个格子。
11. 玩家点击可见物品后,物品飞入备选栏。
12. 备选栏中每凑齐 `3` 个相同物品 id 自动消除。
13. 清空圆形空间中全部物品即胜利。
14. 倒计时结束或备选栏满即失败。
15. 胜利 / 失败后展示结算界面。
16. 点击、入槽、消除、失败、胜利的即时反馈效果由前端先行呈现,后端负责权威确认、状态落库和成绩可信性。
---
## 5. 明确不做
首版 demo 明确不做:
1. 不做抓大鹅和 `Match 3D` 的模板分裂。
2. 不做多关卡关卡链。
3. 不做排行榜正式展示。
4. 不做道具,但需要预留功能口。
5. 不做洗牌、重置、旋转、放大等局内操作。
6. 不做真实 3D 模型。
7. 不做真实 3D 物理遮挡。
8. 不做真实物理碰撞结算。
9. 不做必须试玩通关才能发布的门槛。
10. 不把玩法规则说明长文默认写入 UI 面板。
11. 不把前端即时反馈当作最终规则真相。
12. 不使用 `server-node` 或 PostgreSQL 新增实现。
---
## 6. 创作端设计
## 6.1 创作方式
Match3D 首版参考拼图早期的 Agent 对话收集锚点,而不是拼图后期的纯表单入口。
Agent 的职责是帮助用户确认可以直接编译 demo 的最小配置:
1. 题材主题。
2. 需要消除次数。
3. 游戏难度。
## 6.2 必填配置
### 题材主题
题材决定后续生成或选择物品素材的方向。用户可以自定义主题,例如水果、玩具、食物、符号等。
首版 demo 不接入真实图片生成。运行态可消除物统一使用纯色几何体表现,不使用透明气泡,也不在图案上放文字标识。题材仍决定后端生成的 `visualKey` 和尺寸比例,但前端首版用差异化颜色与几何造型表现可消除物,例如圆形、三角形、菱形、五角星、梯形、平行四边形等,避免玩家在堆叠状态下难以辨认。
水果图形资产需要具备常识可感知的相对大小关系,但不要求真实比例绝对精准。首版固定规则为:西瓜明显大于苹果;苹果、橙子、梨、桃子为中等尺寸;葡萄、李子、青柠等小型水果略小。该尺寸由后端运行态物品 `radius` 下发,前端只按快照表现。
### 需要消除次数
用户输入任意正整数。
该字段不是胜利条件,而是本局总物品规模配置:
```text
本局最终物品数 = 需要消除次数 * 3
```
例如用户填写需要消除 `4` 次,则场景中最终生成 `12` 件物品。最终目标仍然是清空空间中全部物品。
### 难度
用户输入 `1~10` 的数字,代表从低到高的难度感受。
首版 demo 中,用户只需凭感觉选择难度;具体难度规则由系统内部解释。后续优化阶段再细化难度曲线、生成算法和遮挡策略。
## 6.3 自动配置
如果用户不想逐项填写,系统可以自动补全题材、需要消除次数和难度。
自动补全后的配置必须展示给用户确认,用户确认后才能开始创造。
## 6.4 参考图片
题材阶段允许用户上传参考图片。
首版只支持图片参考,不支持视频参考。参考图片用于影响题材表现,不作为运行时规则裁决依据。
---
## 7. 结果页设计
## 7.1 结果页定位
结果页是待发布作品的最小工作台,不是只读总结页。
首版结果页尽量复用其他玩法模板的结果页结构,重点保证基础信息编辑、试玩和发布链路跑通。
## 7.2 必备字段
结果页至少包含:
1. 游戏名称。
2. 标签。
3. 封面图。
4. 题材主题。
5. 需要消除次数。
6. 难度。
7. 试玩入口。
8. 发布入口。
## 7.3 素材生成边界
首版结果页暂时不生成额外素材。
后续如果需要生成题材物品、伪 3D 物品、场景背景或封面图,需要先补充本文档或新增技术方案,再进入编码。
## 7.4 发布前试玩
发布前需要支持试玩当前关卡。
试玩目的不是强制验证通关,而是帮助用户确认:
1. 题材表现是否符合预期。
2. 物品数量是否符合预期。
3. 难度体感是否符合预期。
试玩过程中必须支持随时停止并返回修改配置。
## 7.5 发布门槛
发布时必须完成基础信息填写。
首版不要求用户试玩通关后才能发布。
---
## 8. 运行时玩法系统
## 8.1 单局时长
一局暂定 `10` 分钟倒计时。
倒计时结束时,如果玩家未清空圆形空间中的全部物品,则关卡失败。
该设计后续可根据体验调整,但调整前需要先更新文档。
## 8.2 场景空间
运行时主交互空间是一个有边界的圆形空间。
1. 圆形空间使用俯视角。
2. 背景环境资源后续可以尝试伪 3D 视角效果。
3. 圆形空间边界是中间交互图案的边界。
4. 物品不能超出圆形边界,也不能被边界压住或裁切。
5. 运行态快照中的 `x / y / radius` 使用前端可直接渲染的 `0~1` 归一化坐标;圆心固定为 `(0.5, 0.5)`,圆形可用半径为 `0.5`
6. 后端生成和前端兜底渲染都必须满足 `distance((x, y), (0.5, 0.5)) + radius <= 0.5 - safeMargin``safeMargin` 至少覆盖圆形边框和视觉阴影,避免可消除图案贴边裁切。
## 8.3 物品生成规模
首版按用户填写的需要消除次数计算总物品数:
```text
totalItemCount = clearCount * 3
```
每种物品数量必须是 `3` 的倍数,避免生成无法通关的局。
## 8.4 阶段陆续生成
每局物品允许阶段陆续生成。
玩家启动游戏时,圆形空间里需要给玩家“已经装满”的视觉感受。
根据参考游戏推算,首次初始刷新时表层约 `30~40` 件物品。首版需注意性能消耗,具体刷新规则在 demo 体验后继续优化。
## 8.5 物品资产
首版 demo 使用 2D 图案素材。
1. demo 至少提供 `10` 种颜色与几何造型组合素材。
2. 当题材为水果时,后端仍可切换到 `10` 种水果视觉键和尺寸比例,但前端首版必须把这些视觉键映射为无文字的纯色几何体,不能显示为水果图、透明气泡或文字标记。
3. 后续可以尝试替换为伪 3D 或 3D 模型。
4. 用户题材主题后续会映射为符合常识预期的物品集合。
示例:水果题材可以对应红色苹果、黄色香蕉、紫色葡萄等。
## 8.6 物品 id 与图案
同样物品按物品 id 判断。
1. 每个物品有唯一类型 id。
2. 当前 demo 中,一个物品 id 对应一个图案。
3. 需要预留一个物品 id 对应多个图案的设计空间。
## 8.7 遮挡与点击
圆形空间里的物品可以重叠、遮挡、堆叠。
首版使用 2D 逻辑实现遮挡和点击反馈:
1. 被完全遮挡的物品不允许点击。
2. 如果物品有局部露出,且露出区域可被点击选中,则允许点击。
3. 前端基于后端下发的物品层级、位置、半径和可点击快照,执行即时命中检测与选中反馈。
4. 后端收到点击意图后做权威确认;如果确认失败,前端必须回滚本次即时反馈。
5. 具体露出区域判定使用 2D 逻辑的最优方案,不做真实 3D 遮挡。
## 8.8 点击入槽
玩家点击可见物品后,前端立即播放按压、选中和飞行动画,把物品表现为飞入下方备选栏。
飞行动画过程中,物品不再与其他物品产生碰撞。
前端播放即时反馈的同时,必须向后端提交点击意图。后端确认后固化入槽结果;后端拒绝时,前端恢复物品位置和备选栏表现。
## 8.9 备选栏
下方备选栏固定为 `7` 个格子。
1. 每次点击进入即时反馈流程后,前端先把物品表现为进入备选栏。
2. 备选栏中每出现 `3` 个相同物品 id前端立即播放自动消除效果并腾出格子。
3. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正。
4. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准。
## 8.10 胜利
圆形空间内全部物品被消除后,前端立即播放胜利动画。
正式胜利界面、使用时间和成绩记录以后端确认的运行态为准。
胜利结算页至少展示:
1. 结果状态。
2. 使用时间。
3. 再来一局按钮。
4. 返回作品详情的通用按钮。
## 8.11 失败
失败条件:
1. 倒计时结束。
2. 备选栏满。
倒计时归零或备选栏满时,前端立即展示失败过渡;正式失败原因和完成进度以后端确认的运行态为准。
失败结算页至少展示:
1. 失败原因。
2. 关卡完成进度。
3. 重新开始按钮。
4. 返回上层按钮。
---
## 9. 难度设计
## 9.1 首版难度口径
首版 demo 只有一个关卡,难度递增体感来自单局动态加载过程。
用户输入的难度 `1~10` 暂作为后端布局和生成算法参数,不在 UI 中展示具体规则说明。
## 9.2 已确认的难度方向
后续难度优化需要围绕以下方向细化:
1. 可选物品逐渐变小,更容易误触或更难分辨。
2. 空间表层直观看到可凑成 `3` 个消除的类型变少。
3. 更多可消除组合被堆叠在下方,玩家需要利用备选栏作出决策。
4. 倒计时带来持续压力。
## 9.3 待体验后冻结
以下规则需要 demo 体验后再冻结:
1. 不同难度对应的物品数量、尺寸、遮挡层数和可见组合比例。
2. 阶段加载节奏。
3. 首屏表层物品数量的性能上限。
4. 局内动态难度变化公式。
---
## 10. 前后端职责边界
## 10.1 后端职责
后端必须作为规则真相源,负责:
1. 创建玩法草稿。
2. 编译运行时初始局面。
3. 生成物品序列与布局。
4. 下发前端即时反馈所需的物品位置、层级、半径、可点击快照和版本号。
5. 权威确认玩家点击意图是否合法。
6. 权威确认入槽结果。
7. 权威确认 `3` 个相同物品 id 消除。
8. 权威确认备选栏满失败。
9. 权威确认倒计时结束失败。
10. 权威确认清空空间胜利。
11. 记录成绩所需的基础数据。
## 10.2 前端职责
前端负责所有游戏过程中需要即时呈现的反馈效果:
1. 展示 Agent 创作界面。
2. 展示结果页和基础编辑表单。
3. 上传参考图片。
4. 展示运行态场景、物品、倒计时和备选栏。
5. 基于最新后端快照执行 2D 命中检测、悬停、按压和选中反馈。
6. 发送玩家点击意图。
7. 在等待后端确认期间,先行播放飞入、入槽、三消、腾格、胜利和失败过渡效果。
8. 收到后端确认后,把本地表现校正到权威快照。
9. 展示结算界面。
前端可以做即时表现预判,但不得把预判结果作为最终规则真相或成绩来源。
## 10.3 防作弊要求
首版即按正式版本搭建“前端即时反馈 + 后端权威确认”的链路。
前端不可信任本地点击、消除、胜利或成绩结果;所有关键状态必须以后端确认后的快照为准。
为了保证手感,前端可以先行展示操作反馈;为了防作弊,发布成绩、结算状态、消除计数和运行态持久化必须以后端确认为准。
---
## 11. 状态结构建议
## 11.1 创作配置
```ts
interface Match3DCreatorConfig {
themeText: string;
referenceImageSrc?: string;
clearCount: number;
difficulty: number;
}
```
字段说明:
1. `themeText`:题材主题。
2. `referenceImageSrc`:可选参考图片。
3. `clearCount`:需要消除次数,必须为正整数。
4. `difficulty`:难度,范围为 `1~10`
## 11.2 作品结构
```ts
interface Match3DWorkProfile {
profileId: string;
ownerUserId: string;
sourceSessionId: string;
gameName: string;
themeText: string;
summaryText: string;
tags: string[];
coverImageSrc: string;
clearCount: number;
difficulty: number;
publicationStatus: 'Draft' | 'Published';
playCount: number;
updatedAt: number;
publishedAt?: number;
}
```
## 11.3 运行态快照
```ts
interface Match3DRunSnapshot {
runId: string;
profileId: string;
status: 'Running' | 'Won' | 'Failed' | 'Stopped';
startedAtMs: number;
durationLimitMs: number;
remainingMs: number;
clearCount: number;
totalItemCount: number;
clearedItemCount: number;
traySlots: Match3DTraySlot[];
items: Match3DItemSnapshot[];
failureReason?: 'TimeUp' | 'TrayFull';
}
```
## 11.4 物品快照
```ts
interface Match3DItemSnapshot {
itemInstanceId: string;
itemTypeId: string;
visualKey: string;
x: number;
y: number;
radius: number;
layer: number;
state: 'InBoard' | 'Flying' | 'InTray' | 'Cleared';
clickable: boolean;
}
```
`Flying` 可以作为前端表现态使用,不要求后端把飞行动画过程逐帧落库。后端只需要确认物品是否从 `InBoard` 进入 `InTray``Cleared`
## 11.5 备选栏快照
```ts
interface Match3DTraySlot {
slotIndex: number;
itemInstanceId?: string;
itemTypeId?: string;
visualKey?: string;
}
```
---
## 12. 工程落点建议
## 12.1 纯领域 crate
新增:
```text
server-rs/crates/module-match3d
```
职责:
1. 创作配置校验。
2. 物品生成规划。
3. 2D 遮挡与可点击判定。
4. 点击入槽规则。
5. 三个同物品 id 消除规则。
6. 胜负判定。
7. 成绩基础数据计算。
## 12.2 SpacetimeDB 表
后续技术方案需冻结至少以下表:
1. `match3d_agent_session`
2. `match3d_agent_message`
3. `match3d_work_profile`
4. `match3d_runtime_run`
如表结构变化,必须同步对齐 `migration.rs`
## 12.3 Procedure
后续技术方案需冻结至少以下 procedure
1. `create_match3d_agent_session`
2. `submit_match3d_agent_message`
3. `compile_match3d_draft`
4. `update_match3d_work`
5. `publish_match3d_work`
6. `start_match3d_run`
7. `stop_match3d_run`
8. `click_match3d_item`
9. `restart_match3d_run`
10. `get_match3d_run`
## 12.4 shared contracts
建议新增:
```text
server-rs/crates/shared-contracts/src/match3d_agent.rs
server-rs/crates/shared-contracts/src/match3d_works.rs
server-rs/crates/shared-contracts/src/match3d_runtime.rs
packages/shared/src/contracts/match3dAgent.ts
packages/shared/src/contracts/match3dWorks.ts
packages/shared/src/contracts/match3dRuntime.ts
```
## 12.5 api-server facade
建议新增 Match3D 独立路由,不挂在其他玩法路由下:
```text
POST /api/creation/match3d/sessions
POST /api/creation/match3d/sessions/:sessionId/messages
POST /api/creation/match3d/sessions/:sessionId/compile
PATCH /api/creation/match3d/works/:profileId
POST /api/creation/match3d/works/:profileId/publish
POST /api/runtime/match3d/works/:profileId/runs
POST /api/runtime/match3d/runs/:runId/stop
POST /api/runtime/match3d/runs/:runId/click
POST /api/runtime/match3d/runs/:runId/restart
GET /api/runtime/match3d/runs/:runId
```
---
## 13. 发布与分发
## 13.1 首版发布
首版发布需要完成基础信息:
1. 游戏名称。
2. 标签。
3. 封面图。
4. 题材主题。
5. 需要消除次数。
6. 难度。
## 13.2 广场接入
最终版本需要接入:
1. 首页。
2. 分类页。
3. 作品广场。
4. 他人作品游玩。
5. 后续关卡推荐。
首版 demo 可先不完整实现广场推荐,但作品结构必须预留正式发布字段。
## 13.3 成绩记录
首版记录并预留:
1. 用户 id。
2. 是否通关。
3. 通关时间。
4. 失败原因。
排行榜展示首版暂不做,后续优化可能新增。
## 13.4 二次编辑
已发布作品需要支持二次编辑。
编辑后仍应保留同一个作品归属,不应因为再次发布创建重复作品。
---
## 14. UI 要求
## 14.1 创作入口
创作入口只展示必要信息,不默认堆叠玩法规则说明。
## 14.2 Agent 工作区
Agent 每轮优先追问最影响 demo 生成的一个问题。
已确认的信息应清晰展示给用户确认:
1. 题材主题。
2. 需要消除次数。
3. 难度。
## 14.3 结果页
结果页保持清爽,复用平台已有作品结果页风格。
点击按钮弹出独立面板时,不实现成在当前面板下面展开内容。
## 14.4 运行态
运行态需要移动端优先:
1. 圆形空间占据主要视觉区域。
2. 下方 `7` 格备选栏固定清晰。
3. 倒计时可见但不遮挡物品。
4. 点击、飞入、消除反馈清楚。
5. 胜利 / 失败结算面板独立展示。
---
## 15. 验收标准
首版 PRD 对应 demo 验收标准:
1. 用户可从平台创作入口进入“抓大鹅”模板。
2. Agent 能确认题材、需要消除次数和难度。
3. 用户可上传参考图片。
4. 系统可生成待发布结果页。
5. 用户可编辑游戏名称、标签、封面图等基础信息。
6. 用户可发布前试玩,且试玩失败不阻断发布。
7. 运行态能展示圆形空间、倒计时、物品和 `7` 格备选栏。
8. 物品可重叠、遮挡、堆叠。
9. 被完全遮挡物品不可点击,露出可点击区域的物品可点击。
10. 点击通过后物品飞入备选栏。
11. 备选栏中 `3` 个相同物品 id 自动消除。
12. 清空空间中全部物品后胜利。
13. 倒计时结束或备选栏满后失败。
14. 胜利结算展示使用时间。
15. 失败结算展示完成进度和重新开始按钮。
16. 局内即时反馈由前端先行呈现,关键状态以后端确认快照校正。
17. 相关中文文档通过编码检查。
---
## 16. 推荐落地顺序
## 阶段 A冻结技术方案
1. 基于本文补 `Match3D` 技术落地方案。
2. 冻结表结构、procedure、DTO、api-server 路由。
3. 对齐 `migration.rs`
## 阶段 B创作与结果页
1. 新增平台创作入口。
2. 接入 Agent 会话。
3. 编译草稿并进入结果页。
4. 复用基础信息编辑和发布链。
## 阶段 C后端运行态
1. 新增 `module-match3d` 规则。
2. 新增 SpacetimeDB 运行态表和 procedure。
3. 实现开始、点击确认、消除确认、失败确认、胜利确认。
## 阶段 D前端运行态
1. 展示圆形空间和 2D 物品。
2. 展示 `7` 格备选栏。
3. 接入点击接口和后端快照。
4. 补点击命中、飞入、入槽、消除、腾格、胜负过渡等即时反馈。
5. 补后端确认失败时的前端回滚和快照校正。
## 阶段 E分发与成绩预留
1. 接入首页、分类页和广场投影。
2. 记录成绩基础数据。
3. 预留排行榜和后续关卡推荐。
---
## 17. 一句话结论
Match3D 首版不是临时前端 demo而是以“抓大鹅”模板为外壳、以前端即时反馈保证手感、以后端权威确认保证规则可信、以独立玩法域为工程边界的单局经典消除玩法链路首轮先跑通题材创作、结果页、试玩、发布和单局清空胜负闭环。

View File

@@ -13,7 +13,7 @@
-> 选择“拼图玩法”
-> Agent 聊天收束高杠杆锚点
-> 生成拼图结果页
-> 创作者生成并确认拼图图片
-> 百梦主生成并确认拼图图片
-> 发布到拼图广场
-> 玩家从广场进入第 1 关
-> 全屏拼图运行时
@@ -26,7 +26,7 @@
## 1. 一句话定义
创作者通过 Agent 对话确定拼图作品的高杠杆视觉锚点再由系统生成结果页、AI 生成拼图图片并发布到广场;玩家进入游戏后,在全屏拼图画布中通过交换、合并、拖动和拆分完成关卡,并沿着“相似题材优先、同作者次优先”的关卡链持续游玩。
百梦主通过 Agent 对话确定拼图作品的高杠杆视觉锚点再由系统生成结果页、AI 生成拼图图片并发布到广场;玩家进入游戏后,在全屏拼图画布中通过交换、合并、拖动和拆分完成关卡,并沿着“相似题材优先、同作者次优先”的关卡链持续游玩。
---
@@ -78,28 +78,28 @@
- 拼图关卡名
- AI 生成拼图图片的功能
- 图片题材标签
4. 创作者发布后的拼图作品必须进入平台广场。
4. 百梦主发布后的拼图作品必须进入平台广场。
5. 玩家从广场进入某个作品时,第 1 关必须先显示当前作品本身。
6. 第 2 关及以后必须按照“标签相似度权重 `70%` + 同作者权重 `30%`”选择下一关。
7. 游戏运行时必须全屏展示拼图画布。
8. 新游戏进入时难度必须从 `3*3` 开始,完成 `3`后切为 `4*4`,后续持续为 `4*4`
8. 新游戏进入时难度必须从`1` 关的 `3*3` 开始,并按关卡配置推进到 `4*4``5*5``6*6``7*7`;第 `11`起每 `6` 关循环复用第 `5~10` 关配置
9. 拼图运行时必须支持:
- 点击选择两块并交换
- 正确相邻后自动合并
- 合并块整体拖动
- 单块拖到合并块位置时拆分合并块
10. 游戏画面必须显示作者信息和关卡名。
11. 前端只负责表现和交互输入,逻辑、数据、关卡裁决、推荐计算、状态存储全部放到 `server-rs` 后端,由 `Axum + SpacetimeDB + OSS` 方案承接
11. 拼块交换、拖动、合并、拆分和本关通关判定由前端即时裁决;`server-rs` 继续承接素材、开局、下一关推荐、扣费、排行榜和跨端服务侧状态
### 第一版单机例外说明 2026-04-24
### 运行态前端裁决说明 2026-04-29
为了先把拼图玩法跑通,第一版运行态采用单机本地版本,作为上面总原则的阶段性例外
为了保证拖动手感和正式链路玩法一致,拼图运行态采用前端即时裁决
1. Agent 会话、结果页草稿、正式候选图生成、封面确认、发布、作品读取,仍然全部走 Rust 后端。
2. 进入拼图玩法后`run` 只在前端本地内存中存在
3. 交换、拖动、通关判断不写回后端。
4. 关闭玩法后不保留本次运行态,不做断点续玩
5. 后续如果要做跨端续玩多端同步或排行榜,再把运行态真相源收回后端。
2. 进入拼图玩法后,拼块布局、合并组、拆分结果和本关通关状态由前端基于当前 `PuzzleRunSnapshot` 计算
3. 交换、拖动、合并、拆分、通关判断不写回后端。
4. 正式 run 的下一关推荐、道具扣费、暂停计时同步、排行榜提交继续走后端
5. 后续如果要做跨端续玩多端同步,必须先更新本文档,再决定哪些运行态真相源收回后端。
---
@@ -109,12 +109,12 @@
1. 不做旋转拼块。
2. 不做异形拼块。
3. 不做时间限制和失败倒计时。
4. 不做提示系统、道具系统和体力系统。
3. 初版不做时间限制和失败倒计时`2026-04-29` 起运行时升级为限时关卡,详见 `docs/technical/PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md`
4. 初版不做提示系统、道具系统和体力系统`2026-04-29` 起先落地提示、查看原图、冻结时间三种拼图道具
5. 不做复杂剧情文本或玩法说明文本默认堆在 UI 中。
6. 不做独立于平台创作中心之外的新创作站点。
7. 不做前端本地计算下一关推荐结果。
8. 不做前端本地裁决拼块合并、拆分和关卡完成
8. 不做前端本地计算下一关推荐、扣费或排行榜;拼块交换、拖动、合并、拆分和本关通关判定由前端即时裁决
9. 不把拼图玩法继续命名挂在 `customWorld``rpgWorld` 老前缀下。
---
@@ -129,7 +129,7 @@
创建拼图作品
-> Agent 聊天收束 5 个视觉锚点
-> 生成结果页
-> 创作者确认关卡名、标签、图片
-> 百梦主确认关卡名、标签、图片
-> 发布到拼图广场
```
@@ -137,12 +137,12 @@
### 5.1.1 已发布作品二次编辑
创作者在“我的创作”中点击自己已发布的拼图作品时,不进入只读详情页,而是回到该作品绑定的拼图结果页继续编辑。独立的“体验”按钮仍然直接进入第 1 关,不与编辑入口混用。
百梦主在“我的创作”中点击自己已发布的拼图作品时,不进入只读详情页,而是回到该作品绑定的拼图结果页继续编辑。独立的“体验”按钮仍然直接进入第 1 关,不与编辑入口混用。
落地规则:
1. 已发布拼图作品必须优先通过 `sourceSessionId` 恢复原 Agent session。
2. 恢复后的结果页沿用原草稿、当前正式图、标题、摘要和标签;创作者可以继续改标题、摘要、标签,并重新生成图片。
2. 恢复后的结果页沿用原草稿、当前正式图、标题、摘要和标签;百梦主可以继续改标题、摘要、标签,并重新生成图片。
3. 再次点击发布时不得创建新作品,必须覆盖同一个 `profileId / workId`
4. 覆盖发布只更新作品内容、更新时间、发布时间与广场投影;不得清零 `playCount`,不得改变作品归属。
5. 如果历史作品缺少 `sourceSessionId`,前端只能退回作品详情,不伪造编辑 session。
@@ -210,9 +210,9 @@
拼图 Agent 必须做到:
1. 优先接住创作者的画面灵感,而不是立刻列问卷。
1. 优先接住百梦主的画面灵感,而不是立刻列问卷。
2. 每轮只追问当前最影响图片生成质量的 `1` 个问题。
3.创作者已经说出足够信息时,优先总结,不重复追问。
3.百梦主已经说出足够信息时,优先总结,不重复追问。
4. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。
- 该动作沿用 RPG 聊天链路,仍走发送消息接口,但请求体必须携带 `quickFillRequested: true`
- 前端不补数据、不伪造锚点状态,只发送“请补充剩余关键字。”作为本轮用户消息。
@@ -255,7 +255,7 @@ interface PuzzleAnchorPack {
## 7.1 结果页定位
拼图结果页是创作者从 Agent 共创转入正式发布前的最小工作台。
拼图结果页是百梦主从 Agent 共创转入正式发布前的最小工作台。
它至少承担 5 件事:
@@ -303,7 +303,7 @@ interface PuzzleAnchorPack {
关卡名生成规则建议如下:
1. 默认由 Agent 根据锚点自动生成 `1` 个正式候选名。
2. 创作者可直接手改。
2. 百梦主可直接手改。
3. 关卡名长度建议控制在 `4~12` 个中文字符。
4. 不允许空标题发布。
@@ -375,7 +375,7 @@ interface PuzzleAnchorPack {
拼图图片的正式资产要求:
1. 官方拼图原图统一使用 `1:1` 正方形比例。
2. 建议第一版正式生成尺寸为 `1536 x 1536`
2. 建议第一版正式生成尺寸为 `1024 x 1024`
3. 图中不允许生成标题字、水印、边框、按钮或 UI。
4. 图像主体必须足够清晰,切成 `4*4` 后仍然有辨识信息。
@@ -502,9 +502,10 @@ tagSimilarityScore =
画面要求:
1. 拼图舞台占满可用全屏区域
2. 真正可操作的拼图棋盘按“最大正方形填满安全区域
2. 真正可操作的拼图棋盘按正方形比例填满安全区域,并在移动端贴近屏幕两侧边缘
3. 棋盘外延空间用同图模糊背景或纯净氛围底承接
4. 不默认堆玩法说明文字
4. 基础单块和合并块都使用圆角,基础单块图片需要被圆角容器裁剪
5. 不默认堆玩法说明文字
## 9.2 HUD 必显信息
@@ -516,21 +517,35 @@ tagSimilarityScore =
本次建议同时显示:
1. 当前关卡序号
2. 当前网格规格,例如 `3x3``4x4`
2. 当前网格规格,例如 `3x3``5x5``7x7`
## 9.3 难度与关卡推进规则
每次新 run 都必须从最低难度开始:
每次新 run 都必须从`1` 关配置开始:
1.`1~3` 关固定为 `3x3`
2.`4` 关开始固定为 `4x4`
3. 后续全部关卡保持 `4x4`
| 关卡 | 切割规格 | 限时 |
| ---------- | -------- | -------------- |
| 第 `1` 关 | `3x3` | `5` 分钟 |
| 第 `2` 关 | `4x4` | `5` 分钟 |
| 第 `3` 关 | `5x5` | `5` 分钟 |
| 第 `4` 关 | `5x5` | `3``30` 秒 |
| 第 `5` 关 | `5x5` | `3``30` 秒 |
| 第 `6` 关 | `6x6` | `4` 分钟 |
| 第 `7` 关 | `5x5` | `3``30` 秒 |
| 第 `8` 关 | `7x7` | `4``30` 秒 |
| 第 `9` 关 | `5x5` | `4` 分钟 |
| 第 `10` 关 | `7x7` | `4``30` 秒 |
`11` 关开始,每 `6` 关循环复用第 `5~10` 关配置。
对应函数建议:
```ts
function resolvePuzzleGridSize(clearedLevelCount: number): 3 | 4 {
return clearedLevelCount >= 3 ? 4 : 3;
function resolvePuzzleLevelConfig(levelIndex: number): {
gridSize: 3 | 4 | 5 | 6 | 7;
timeLimitMs: number;
} {
// 统一从关卡序号解析切割规格和倒计时。
}
```
@@ -625,7 +640,7 @@ V1 规则如下:
## 9.11 重算范围
为了避免前端和后端做整盘重复计算,每次操作后只重算受影响区域:
为了避免前端每次操作后做整盘重复计算,只重算受影响区域:
1. 本次发生交换的源格子
2. 本次发生交换的目标格子
@@ -639,7 +654,32 @@ V1 规则如下:
1. 所有拼块合并成 `1` 个覆盖全盘的大合并块
2. 所有拼块都回到原始正确位置
在正式实现中,建议以后端 `allTilesResolved = true` 作为唯一真相
在正式实现中,前端以本地计算得到的 `allTilesResolved = true` 或关卡 `status = cleared` 作为本关通关真相;后端不再参与拼块布局裁决
## 9.13 限时与失败
`2026-04-29` 起,拼图运行时加入倒计时:
1. 倒计时必须使用第 `9.3` 节的关卡配置函数,不允许在 UI 或本地兜底里按网格规模另写一套时间表。
2.`1~10` 关按配置表执行;第 `11` 关起每 `6` 关循环复用第 `5~10` 关配置。
3. 规定时间内未完成拼图,关卡状态变为 `failed`
4. 弹窗、查看原图覆盖、冻结时间生效期间不消耗倒计时。
5. 通关成绩只统计有效消耗时间,不统计暂停与冻结时间。
## 9.14 底部道具
底部固定 `3` 个道具:
1. `提示`:演示将一个最大块移动到正确位置,但不替玩家移动。
2. `查看原图`:开关按钮,打开后把原图覆盖在拼图画布上,再次点击关闭。
3. `冻结时间`:播放冻结特效并展示冻结剩余时长。
道具使用规则:
1. 点击道具必须弹出独立确认窗口。
2. 确认窗口期间暂停游戏时间。
3. 正式后端运行态每次确认消耗 `1` 光点。
4. 本地调试 run 不伪造钱包扣费,只保持确认、暂停和表现一致。
---
@@ -667,7 +707,7 @@ interface PuzzleProfile {
interface PuzzleRuntimeLevelSnapshot {
runId: string;
levelIndex: number;
gridSize: 3 | 4;
gridSize: 3 | 4 | 5 | 6 | 7;
profileId: string;
levelName: string;
authorDisplayName: string;
@@ -712,7 +752,7 @@ interface PuzzleRunSnapshot {
entryProfileId: string;
clearedLevelCount: number;
currentLevelIndex: number;
currentGridSize: 3 | 4;
currentGridSize: 3 | 4 | 5 | 6 | 7;
playedProfileIds: string[];
previousLevelTags: string[];
currentLevel: PuzzleRuntimeLevelSnapshot | null;
@@ -730,17 +770,17 @@ interface PuzzleRunSnapshot {
1. 展示 Agent 聊天界面
2. 展示结果页
3. 展示拼图画布、选中态、拖动反馈、合并反馈
4. 发起交换、拖动、发布、开始游戏等请求
4. 即时裁决拼块交换、拖动、合并、拆分和本关通关状态
5. 发起发布、开始游戏、下一关、道具、排行榜等请求
前端不负责:
1. 解析锚点完成度
2. 计算推荐下一关
3. 计算标签相似度
4. 判定哪些块应该合并
5. 判定合并块何时拆分
6. 判定通关
7. 保存 run 状态
4. 计算下一关推荐
5. 保存跨端 run 状态
6. 执行道具扣费或排行榜写入
## 11.2 后端职责
@@ -752,10 +792,9 @@ interface PuzzleRunSnapshot {
4. 发布作品到拼图广场
5. 创建 run
6. 初始化关卡棋盘
7. 裁决交换、合并、拆分、拖动结果
8. 判定通关
9. 计算下一关推荐
10. 保存当前 run 快照
7. 计算下一关推荐
8. 保存当前 run 的关卡入口、计时、道具和排行榜相关状态
9. 兼容保留旧交换接口;拖动接口不作为 Rust API 默认能力暴露,前端不依赖后端裁决拼块布局
---
@@ -772,11 +811,12 @@ interface PuzzleRunSnapshot {
- 结果页交互
- 拼图画布渲染
- HUD、选中态、拖动态、合并反馈表现
- 拼块交换、拖动、合并、拆分与本关通关判定
3. 前端不得承担:
- 推荐算法
- run 状态持久化
- 拼块合并与拆分裁决
- 通关判定
- run 状态跨端持久化
- 道具扣费
- 排行榜写入
4. 若后续拼图运行时需要实时订阅或读取 `SpacetimeDB` 数据,前端接入必须显式以 `spacetimedb-typescript` 约束为准。
### HTTP 与外部副作用层
@@ -793,15 +833,16 @@ interface PuzzleRunSnapshot {
### 状态真相源
1. 拼图玩法的运行时状态、作品状态、Agent 会话状态、广场投影状态,统一以 `SpacetimeDB` 为唯一真相源。
2. `SpacetimeDB` 中应承担:
1. 拼图玩法的作品状态、Agent 会话状态、广场投影状态、下一关推荐、道具扣费和排行榜等服务侧状态,统一以 `SpacetimeDB` 为唯一真相源。
2. 当前关卡的拼块布局、合并组、拆分结果和本关通关状态,运行中以前端即时计算结果为准,不要求每一步写回 `SpacetimeDB`
3. `SpacetimeDB` 中应承担:
- 拼图作品 profile 表
- 拼图 Agent session / message / operation 表
- 拼图 run 与关卡状态
- 拼块与合并组状态
- 拼图 run 入口、关卡服务状态与排行榜
- 下一关候选、道具扣费和榜单聚合所需状态
- 拼图广场投影表
- 标签相似度计算所需的规范化标签字段
3. 所有真正修改状态的行为必须通过 reducer 或 procedure 完成,不能由 Axum 或前端偷偷改状态。
4. 所有服务侧状态修改必须通过 reducer 或 procedure 完成,不能由 Axum 或前端偷偷改服务侧状态。
### 资产存储
@@ -1011,9 +1052,12 @@ interface PuzzleRunSnapshot {
1. `POST /api/runtime/puzzle-runtime/runs`
2. `GET /api/runtime/puzzle-runtime/runs/:runId`
3. `POST /api/runtime/puzzle-runtime/runs/:runId/swap`
4. `POST /api/runtime/puzzle-runtime/runs/:runId/drag`
5. `POST /api/runtime/puzzle-runtime/runs/:runId/next-level`
3. `POST /api/runtime/puzzle-runtime/runs/:runId/next-level`
4. `POST /api/runtime/puzzle-runtime/runs/:runId/pause`
5. `POST /api/runtime/puzzle-runtime/runs/:runId/props`
6. `POST /api/runtime/puzzle-runtime/runs/:runId/leaderboard`
`swap` 兼容接口可以保留,但前端默认不再调用;`drag` 不作为默认 HTTP 入口暴露。拼块布局由前端即时裁决。
---
@@ -1059,7 +1103,7 @@ interface PuzzleRunSnapshot {
建议布局:
1. 顶部轻量 HUD
2. 中间最大正方形拼图棋盘
2. 中间正方形拼图棋盘,移动端贴近屏幕两侧边缘
3. 底部不常驻大段文案
如需操作提示,只允许短暂轻提示,不允许占据长期版面。
@@ -1116,7 +1160,7 @@ interface PuzzleRunSnapshot {
完成标准:
1. 创作者能从平台进入拼图 Agent 工作区
1. 百梦主能从平台进入拼图 Agent 工作区
2. 能通过聊天生成结果页草稿
## 阶段 B再做结果页与图片资产
@@ -1130,14 +1174,14 @@ interface PuzzleRunSnapshot {
完成标准:
1. 创作者能生成正式拼图图片并发布
1. 百梦主能生成正式拼图图片并发布
2. 作品能进入拼图广场
## 阶段 C再做拼图运行时核心循环
先做:
1. `3x3 / 4x4` 切图
1. `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 切图
2. 点击两块交换
3. 正确连接自动合并
4. 合并块整体拖动
@@ -1172,14 +1216,15 @@ interface PuzzleRunSnapshot {
4. 发布后的拼图作品能进入平台广场。
5. 玩家从广场进入时,第 `1` 关必定是当前作品本身。
6.`2` 关及以后按照“标签相似度 `70%` + 同作者 `30%`”计算下一关。
7. 新 run `3` 关为 `3x3`,之后固定为 `4x4`
7. 新 run 的关卡切割和倒计时符合第 `9.3` 节配置,并且第 `11` 关起按第 `5~10` 关配置循环
8. 运行时支持点击两块交换。
9. 交换后正确相邻的块会自动合并。
10. 合并块可以整体拖动。
11. 单块拖到合并块位置时可以拆分合并块。
12. 游戏画面能显示作者信息和关卡名。
13. 拼图玩法没有继续错误复用 `customWorld``rpgWorld` 域命名
14. 新增脚本命名符合平台现有规范
13. 基础单块和合并块都使用圆角,基础单块图片不会露出直角
14. 拼图玩法没有继续错误复用 `customWorld``rpgWorld` 域命名
15. 新增脚本命名符合平台现有规范。
---
@@ -1187,4 +1232,4 @@ interface PuzzleRunSnapshot {
这次平台新增拼图玩法,正确的做法不是只补一个拼图画布,而是:
**把拼图作为平台内独立玩法类型接进现有 Agent-first 创作中心、结果页、发布链、广场分发链和运行时链,让创作者先收束高杠杆视觉锚点,让玩家在全屏交换-合并-拆分的拼图循环中持续游玩。**
**把拼图作为平台内独立玩法类型接进现有 Agent-first 创作中心、结果页、发布链、广场分发链和运行时链,让百梦主先收束高杠杆视觉锚点,让玩家在全屏交换-合并-拆分的拼图循环中持续游玩。**

View File

@@ -630,7 +630,7 @@ SSE 事件:
1. 增加背景音乐和环境音,但不改变四帧三段主链。
2. 为移动端生成 `9:16` 竖版裁切版本。
3. 支持创作者手动上传某张关键帧,再生成相邻视频。
3. 支持百梦主手动上传某张关键帧,再生成相邻视频。
4. 支持发布后版本化替换开场动画。
5. 支持用第四幕直接生成开局场景动态背景。
6. 支持把开场动画拆出的关键帧回流为作品详情页轮播素材。

View File

@@ -22,7 +22,7 @@
接成一条新的稳定流程:
**每个场景由创作者在工具中配置为 `2~5` 幕;每一幕都绑定独立背景图和相遇 NPC 顺序;每一幕的第一个 NPC 视为主角色;运行时按幕切换背景和可遇对象,并根据主角色当前好感度裁决聊天轮数与第 5 轮收束方式。**
**每个场景由百梦主在工具中配置为 `2~5` 幕;每一幕都绑定独立背景图和相遇 NPC 顺序;每一幕的第一个 NPC 视为主角色;运行时按幕切换背景和可遇对象,并根据主角色当前好感度裁决聊天轮数与第 5 轮收束方式。**
本次还追加一条必须和草稿生成阶段一起落地的约束:
@@ -31,13 +31,13 @@
补充口径修正:
1. `scene_chapter` 在本期继续保留为数据层 / 编译层 / 运行时层概念。
2. `scene_chapter` 不作为创作者可见的独立 Tab、独立卡片或独立导航入口。
3. 创作者配置多幕的唯一入口,是现有“场景”列表里的场景编辑弹层。
2. `scene_chapter` 不作为百梦主可见的独立 Tab、独立卡片或独立导航入口。
3. 百梦主配置多幕的唯一入口,是现有“场景”列表里的场景编辑弹层。
4. 每一幕的 NPC 配置区必须直接叠在当前幕背景预览上,以“对面角色站位”的方式呈现;三个站位既是预览,也是编辑入口。
5. 幕编辑站位中每个角色只显示角色形象与名称,不展示额外信息块、规则说明或说明性标签。
6. 幕内小预览的构图固定为左侧玩家、右侧当前幕角色;右侧三个站位采用一前两后。
前排主角色的 y 轴必须与玩家角色对齐后排两个角色必须同一列、x 轴对齐,上下分布,且后排整体的 y 轴中点与前排主角色保持一致。
7. 新建幕默认仅预置 1 个主角色槽位内容,其余槽位留空,等待创作者补充。
7. 新建幕默认仅预置 1 个主角色槽位内容,其余槽位留空,等待百梦主补充。
8. 角色名称显示在角色形象上方,角色渲染不附带方形 UI 底板。
9. 世界档案的场景详情页不再单独展示“场景图片”和“场景内 NPC”字段相关兼容数据统一由多幕配置自动同步回场景对象。
@@ -55,7 +55,7 @@
本次迭代必须同时满足以下目标:
1. 创作者可以在现有创作页面中为每个场景章节配置多幕内容。
1. 百梦主可以在现有创作页面中为每个场景章节配置多幕内容。
2. 每一幕都必须绑定一张正式背景图。
3. 每一幕都可以配置玩家会遇到哪些 NPC并且保留顺序。
4. 每一幕配置的第一个 NPC 必须被系统认定为该幕主角色。
@@ -89,7 +89,7 @@
1. 不新建独立的“场景编辑器”页面。
2. 不把幕推进逻辑放到前端本地计算。
3. 不让创作者直接编辑底层运行时 `ChapterState` 或聊天状态对象。
3. 不让百梦主直接编辑底层运行时 `ChapterState` 或聊天状态对象。
4. 不做多 NPC 并行聊天。
5. 不做每一幕的复杂分支树可视化编辑器。
6. 不把“规则说明文案”默认堆到创作页或游戏 UI 面板里。
@@ -122,7 +122,7 @@
1. 场景章节没有“幕”这一层结构化对象。
2. 背景图是场景级资产,不是幕级资产。
3. NPC 与场景的关系主要还是地点级归属,不是幕级相遇编排。
4. 创作者无法在创作页里明确控制“这一幕谁先出场、谁是主角色”。
4. 百梦主无法在创作页里明确控制“这一幕谁先出场、谁是主角色”。
## 4.2 游戏运行侧现状
@@ -185,7 +185,7 @@
这意味着:
1. 创作者在工具里编辑的是“第几幕”。
1. 百梦主在工具里编辑的是“第几幕”。
2. 运行时仍然只认现有章节阶段枚举。
3. `chapterDirector` 可以继续复用,只是数据来源从“纯 quest 推导”升级成“quest + 幕蓝图联合推导”。
@@ -214,7 +214,7 @@
- `name`
- `description`
- `imageSrc`
- `sceneNpcIds`(仅作为兼容字段,由多幕配置自动派生,不再作为创作者可编辑字段)
- `sceneNpcIds`(仅作为兼容字段,由多幕配置自动派生,不再作为百梦主可编辑字段)
- `connections`
- `sceneChapterBlueprints` 对应的多幕配置
2. 场景配置面板中,开局场景必须复用普通场景同级的配置 UI而不是继续保留一套缩水版表单。
@@ -251,7 +251,7 @@
原因:
1. 当前创作工作区已经进入“先收关键锚点、再逐步扩写”的阶段。
2. 一次铺太多 playable、场景和长尾对象会稀释创作者对第一版底稿的掌控感。
2. 一次铺太多 playable、场景和长尾对象会稀释百梦主对第一版底稿的掌控感。
3. 本期还要把幕级背景图和角色主形象自动挂回草稿,如果对象规模不收束,等待时间和生成成本都会直接失控。
### 5.5.2 幕级出演角色与背景必须由剧情引擎判定
@@ -309,7 +309,7 @@
- 角色主形象是否就绪
- 场景幕背景是否就绪
这样创作者一进入草稿精修工作区,就能直接看到:
这样百梦主一进入草稿精修工作区,就能直接看到:
1. 角色已经带主形象
2. 每个场景章节的每一幕已经带背景图
@@ -369,7 +369,7 @@ interface CustomWorldFoundationDraftSceneChapter {
1. `primaryNpcId` 必须等于 `encounterNpcIds[0]`,不允许单独填写成别的角色。
2. 每幕必须至少有 `1` 个 NPC。
3. 每幕必须有 `backgroundImageSrc``backgroundAssetId`
4. `advanceRule` 由系统按幕位置默认编译,第一版不要求创作者手改。
4. `advanceRule` 由系统按幕位置默认编译,第一版不要求百梦主手改。
## 6.2 发布到运行时的蓝图结构
@@ -416,7 +416,7 @@ sceneChapterBlueprints?: SceneChapterBlueprint[] | null;
原因:
1. 现有 `landmarks` 只足够表达地点,不足够表达幕顺序。
2. 现有 `ChapterState` 是运行时状态,不适合直接兼做创作者蓝图。
2. 现有 `ChapterState` 是运行时状态,不适合直接兼做百梦主蓝图。
3. 独立蓝图层更适合后端编译和发布校验。
## 6.3 聊天状态扩展
@@ -483,9 +483,9 @@ type NpcChatTurnResult = {
新增规则:
1. 创作者从现有“场景”列表点击任一场景卡,进入对应场景编辑弹层。
1. 百梦主从现有“场景”列表点击任一场景卡,进入对应场景编辑弹层。
2. 多幕配置必须作为场景编辑弹层内的一个区块出现,归属于该场景。
3. `scene_chapter` 仅作为保存层和运行时蓝图存在,不单独暴露在创作者导航里。
3. `scene_chapter` 仅作为保存层和运行时蓝图存在,不单独暴露在百梦主导航里。
4. 场景卡片可增加“幕数量”轻量摘要,但第一版不是阻塞项。
## 7.2 场景编辑弹层展示要求
@@ -498,8 +498,8 @@ type NpcChatTurnResult = {
补充约束:
1. “场景图片”不再作为场景详情页里的独立字段展示,创作者只能通过每一幕的“配置背景”入口管理视觉。
2. “场景内 NPC”不再作为场景详情页里的独立字段展示创作者只能通过每一幕角色槽位配置相遇 NPC。
1. “场景图片”不再作为场景详情页里的独立字段展示,百梦主只能通过每一幕的“配置背景”入口管理视觉。
2. “场景内 NPC”不再作为场景详情页里的独立字段展示百梦主只能通过每一幕角色槽位配置相遇 NPC。
3. 为兼容现有运行时与旧数据结构,场景对象上的 `imageSrc / sceneNpcIds` 仍然保留,但必须由多幕配置自动回填,前台不再暴露单独编辑控件,且不能再用 `sceneNpcIds` 限制每幕可选角色。
多幕区块至少展示:
@@ -566,11 +566,11 @@ NPC 配置面板必须支持:
3. 不允许把不存在于当前世界角色池中的 id 写入幕配置。
4. 若主角色未与当前场景或线程建立任何关联,给出发布警告。
5. 存储时继续落到 `encounterNpcIds` 有序数组,槽位从左到右按顺序压缩写入。
6. `sceneNpcIds` 不再作为创作者字段,也不再作为幕角色选择范围;保存时只从所有幕的 `encounterNpcIds` 自动派生兼容值。
6. `sceneNpcIds` 不再作为百梦主字段,也不再作为幕角色选择范围;保存时只从所有幕的 `encounterNpcIds` 自动派生兼容值。
## 7.6 幕预览
创作者在场景编辑弹层里点击“幕预览”后,必须直接进入当前幕的运行时预览。
百梦主在场景编辑弹层里点击“幕预览”后,必须直接进入当前幕的运行时预览。
要求如下:
@@ -633,7 +633,7 @@ interface SceneActRuntimeState {
## 8.3 幕推进规则
第一版不要求创作者手填推进条件,而是由系统按幕位置默认编译:
第一版不要求百梦主手填推进条件,而是由系统按幕位置默认编译:
1.`1` 幕默认 `after_primary_contact`
- 玩家与主角色发生首次有效接触后可进入下一幕判定
@@ -871,7 +871,7 @@ Adventure 主面板在本次迭代中至少增加下面这些表现:
当下面这些结果都成立时,视为本次 PRD 已被正确落地:
1. 创作者可以在现有场景编辑弹层中配置每个场景的多幕。
1. 百梦主可以在现有场景编辑弹层中配置每个场景的多幕。
2. 每个场景章节都可以配置 `2~5` 幕。
3. 每一幕都可以绑定独立背景图。
4. 每一幕都可以配置有序 NPC 列表,第一位自动成为主角色。

View File

@@ -4,7 +4,7 @@
## 0. 目标
把“剩余叙世币 / 游戏时长 / 玩过作品”这一排信息卡,从静态数字展示升级成稳定的个人数据看板,让玩家在进入“我的”页时一眼知道自己的账号资产和游玩投入。
把“光点 / 游戏时长 / 玩过”这一排信息卡,从静态数字展示升级成稳定的个人数据看板,让玩家在进入“我的”页时一眼知道自己的账号资产和游玩投入。
---
@@ -12,9 +12,9 @@
当前三个数字来源并不统一:
1. 叙世币来自当前存档上下文,不等于账号总资产
2. 游戏时长依赖当前快照,不代表全账号累计
3. 玩过作品当前几乎是硬编码推导,不是真实统计
1. 光点来自当前存档上下文,不等于账号总资产
2. 游戏时长依赖当前快照,不代表全账号累计
3. 玩过当前几乎是硬编码推导,不是真实统计
这会导致“我的”页看到的数据不可信。
@@ -39,17 +39,17 @@
## 3. 指标定义
## 3.1 剩余叙世币
## 3.1 光点
定义:
- 当前账号可立即消费的叙世币余额
- 当前账号可立即消费的光点余额
不使用:
- 当前单个存档里的临时货币数值
## 3.2 游戏时长
## 3.2 游戏时长
定义:
@@ -60,7 +60,7 @@
- 只累计进入有效游戏流程的时长
- 后台挂机超阈值后停止累计
## 3.3 玩过作品
## 3.3 玩过
定义:
@@ -80,20 +80,22 @@
点击行为:
1. 叙世币
1. 光点
- 打开资产流水抽屉
2. 游戏时长卡
2. 游戏时长卡
- 打开游玩统计抽屉
3. 玩过作品
- 打开玩过作品列表
3. 玩过卡
- 打开玩过列表
如果本期不做明细页,点击可先无动作,但必须预留可扩展事件位。
## 4.2 展示规则
1. 数字过大时做单位缩略展示
2. 进入页面先展示骨架屏
3. 数据请求失败时展示降级文案,不展示假数字
2. “游戏时长”卡固定以小时为单位展示,短时长不切换成分钟,长时长不切换成天
3. “玩过”卡展示值始终带 `个` 单位,例如 `0个``1个``1.2万个`
4. 进入页面先展示骨架屏
5. 数据请求失败时展示降级文案,不展示假数字
---
@@ -123,14 +125,14 @@
返回:
- 叙世币流水列表
- 光点流水列表
### `GET /api/profile/play-stats`
返回:
- 游玩时长分布
- 玩过作品列表摘要
- 玩过列表摘要
---
@@ -138,7 +140,7 @@
1. 钱包余额从后端钱包台账聚合
2. 游戏时长从运行时会话日志或快照汇总
3. 玩过作品数从有效游玩记录去重计算
3. 玩过数从有效游玩记录去重计算
禁止继续采用:
@@ -152,3 +154,4 @@
2. 切换设备后看板数据一致
3. 没有存档时也能正常展示账号级数据
4. 数据加载失败时页面表现可控
5. “游戏时长”卡展示值始终带 `小时` 单位,例如 `0小时``1.5小时``36小时`

View File

@@ -2,6 +2,8 @@
更新时间:`2026-04-16`
> 2026-04-30 更新:用户侧邀请码填写入口已迁到注册环节,当前落地以 `docs/technical/PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md` 为准;“我的 Tab”不再保留填邀请码入口。
## 0. 目标
把“填邀请码”做成用户激活早期的一次性绑定动作,完成:

View File

@@ -73,7 +73,7 @@
首期奖励建议采用可控方案:
1. 邀请人获得叙世币
1. 邀请人获得光点
2. 被邀请人获得新手奖励
所有奖励必须走台账,不允许前端本地加值。
@@ -164,4 +164,4 @@
1. 用户能看到自己的邀请码与邀请链接
2. 可以一键复制或分享
3. 邀请成功后能看到正确统计
4. 奖励到账后叙世币余额同步变化
4. 奖励到账后光点余额同步变化

View File

@@ -51,11 +51,11 @@
首期只保留两种状态:
1. `普通用户`
2. `叙世会员`
2. `百梦会员`
会员权益首期建议控制在直接可编码的范围:
1. 每日额外叙世币领取额度
1. 每日额外光点领取额度
2. 高级世界模板或创作槽位
3. 更高的云存档上限
4. 会员专属标识
@@ -119,7 +119,7 @@
支付成功后:
1. 刷新会员状态
2. 刷新叙世币余额
2. 刷新光点余额
3. 刷新权益标签
---

View File

@@ -8,7 +8,7 @@
1. 头像编辑
2. 昵称编辑
3. 叙世号展示与复制
3. 百梦号展示与复制
4. 登录方式与绑定状态展示
5. 进入资料编辑抽屉
@@ -22,7 +22,7 @@
- 头像占位
- 昵称
- 叙世
- 百梦
- 登录方式
- 绑定状态
@@ -31,7 +31,7 @@
1. 头像按钮和昵称编辑按钮都直接打开账号弹窗,信息架构混在一起
2. 头像当前只是视觉壳,没有真正的上传与裁剪能力
3. 昵称缺少明确的编辑规则与唯一性策略
4. 叙世号只是前端拼接值,不适合长期作为正式公开识别码
4. 百梦号只是前端拼接值,不适合长期作为正式公开识别码
---
@@ -43,7 +43,7 @@
2. 资料编辑抽屉
3. 头像上传、裁切、保存
4. 昵称编辑、校验、保存
5. 叙世号固定生成与复制
5. 百梦号固定生成与复制
6. 登录方式与账号状态标签展示
## 2.2 本期不做
@@ -63,7 +63,7 @@
- 用户头像
- 用户昵称
- `叙世号`
- `百梦号`
- 登录方式标签
- 账号状态标签
- 资料编辑入口
@@ -85,7 +85,7 @@
- 打开“编辑资料”抽屉,并默认聚焦头像编辑区域
2. 点击昵称右侧编辑按钮
- 打开“编辑资料”抽屉,并默认聚焦昵称输入框
3. 点击叙世号复制按钮
3. 点击百梦号复制按钮
- 直接复制,并给出轻提示
4. 点击登录方式/状态标签
- 不跳页,不弹复杂说明
@@ -125,9 +125,9 @@
4. 不要求全站唯一,但要允许后端做敏感词审核
5. 审核失败时返回明确错误
## 4.3 叙世
## 4.3 百梦
叙世号规则:
百梦号规则:
1. 作为公开可复制识别码
2. 用户创建后固定生成,不允许用户修改
@@ -207,6 +207,6 @@
1. 用户可以上传并保存头像
2. 用户可以修改昵称并实时看到更新
3. 叙世号由后端返回,复制后可正常使用
3. 百梦号由后端返回,复制后可正常使用
4. 未登录或待绑定状态下,不出现无效编辑入口
5. 页面不出现冗长规则说明文案

View File

@@ -83,6 +83,7 @@
8. 子面板返回按钮固定摆在面板右上角
9. 设置首页与各级子面板都必须定义单一滚动容器,列表内容必须可稳定滚动,禁止外层与内层同时争夺滚动
10. 二级或三级面板打开后,下层内容必须进入不可交互状态,并把焦点主动转移到当前面板内;禁止对仍保留焦点的祖先节点使用 `aria-hidden`
11. 右上角头像、账号入口等身份入口直达“账号信息”时,只允许展示“账号信息”面板本身,不再同步弹出或保留“设置与账号安全”首页;只有“设置”入口才打开设置首页
---

View File

@@ -108,6 +108,16 @@
- 最后游玩时间
- 游戏信息
### 3.3.0 拼图存档字段与视觉层级
拼图玩法的存档列表项必须按作品维度展示,不把关卡名当作作品名:
- 主标题展示作品名,来源为服务端存档投影的 `worldName`,视觉层级必须是卡片内最醒目的文本。
- 副标题展示当前可继续入口,格式为 `第 N 关 · 关卡名`;无关卡名时只展示 `第 N 关`
- 卡片中不展示英文 `Archive` / `ARCHIVE` 标签。
- 封面缩略图固定为 `1:1` 正方形比例,使用当前可继续关卡图片。
- 封面图上不再覆盖“最近存档”标签;保存时间独立展示在信息区内。
### 3.3.1 移动端卡片布局约束
- 移动端列表卡片中的封面只能作为独立缩略图或弱化背景层使用,不能直接占满整张卡片并压在正文信息下方。

View File

@@ -35,7 +35,7 @@ TXT 模式核心玩法是一个包含“创作编辑器 -> 测试体验 -> 正
1. 支持创建 TXT 模式作品。
2. 支持 TXT 模式作品的完整创作流程。
3. 支持创作者测试体验。
3. 支持百梦主测试体验。
4. 支持玩家正式游玩。
5. 支持文本模式运行。
6. 支持双会话机制。
@@ -174,9 +174,9 @@ TXT 模式核心玩法必须完整保留双会话机制。
2. 正式继续体验
3. 正式游玩推进
## 7.2 创作者测试/读档会话
## 7.2 百梦主测试/读档会话
创作者测试/读档会话用于:
百梦主测试/读档会话用于:
1. 编辑器内测试体验
2. 指定存档加载

View File

@@ -2,6 +2,10 @@
日期:`2026-04-23`
更新:`2026-04-30`
> 状态说明:本文件中的管理员鉴权、`/admin/api/*` 管理接口、数据库概览与受控 API 调试设计继续有效;同源内嵌 HTML/CSS/JS 后台页面已废弃。后续后台 UI 迁移到独立前端工程,当前 `api-server` 不再挂载 `GET /admin` 页面入口。独立后台前端的产品边界见 [`../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md`](../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),技术方案见 [`ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md`](./ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
## 1. 目标
为当前 Rust `api-server` 增加一套同源后台管理服务,满足以下首版目标:
@@ -10,7 +14,7 @@
2. 支持独立的管理员鉴权,不允许普通玩家 JWT 越权访问。
3. 支持在后台查看当前服务与数据库概览信息。
4. 支持在后台测试当前 `api-server` 已挂载接口。
5. 保持首版工程足够轻量,不新建额外独立服务进程,不引入第二套前端工程。
5. 保持管理能力继续收口在 `server-rs`,管理 UI 由独立后台前端工程承接
## 2. 背景与约束
@@ -24,19 +28,20 @@
1. 后端统一落在 `server-rs`,不回退到 `server-node`
2. 不额外新起独立管理服务进程。
3. 首版以“一个受保护管理域 + 一个同源后台页面”为落地形态
3. 管理 API 继续作为受保护管理域挂载在 `api-server`
4. 数据库信息必须尽量读取真实数据库侧信息,不能只展示硬编码假数据。
## 3. 首版范围
### 3.1 包含
1. `GET /admin`:后台管理页面入口
2. `POST /admin/api/login`:管理员用户名密码登录
3. `GET /admin/api/me`:当前管理员会话信息
4. `GET /admin/api/overview`:服务与数据库概览
5. `POST /admin/api/debug/http`:受控 HTTP 接口调试
6. 基于 Bearer JWT 的管理员鉴权中间件
1. `POST /admin/api/login`:管理员用户名密码登录
2. `GET /admin/api/me`当前管理员会话信息
3. `GET /admin/api/overview`:服务与数据库概览
4. `POST /admin/api/debug/http`:受控 HTTP 接口调试
5. `POST /admin/api/profile/redeem-codes`:兑换码创建/更新
6. `POST /admin/api/profile/redeem-codes/disable`:兑换码停用
7. 基于 Bearer JWT 的管理员鉴权中间件。
### 3.2 不包含
@@ -44,7 +49,7 @@
2. 管理员 refresh cookie / 多端会话管理。
3. 后台直接写库、删库、执行 reducer。
4. 任意 SQL 执行器。
5. 新建独立 React/Vite 管理端工程
5. `api-server` 内嵌 HTML/CSS/JS 后台页面
## 4. 总体方案
@@ -60,13 +65,13 @@
### 4.2 页面形态
后台管理页面采用 `api-server` 直接返回一份内嵌 HTML/CSS/JS 的管理页
后台管理页面不再由 `api-server` 直接返回内嵌 HTML/CSS/JS`api-server` 仅保留管理 API页面由独立后台前端工程调用这些接口
原因:
1. 首版目标是“可用的后台能力”,不是新建一套复杂前端基建
2. 管理页面交互相对简单,直接内嵌更易随服务端一起部署
3. 可以避免新增构建链和静态资源发布路径
1. 管理 UI 需要独立演进,不应继续堆在 Rust 源码字符串中
2. `server-rs` 继续负责鉴权、聚合和写操作,符合前端只做表现的工程约束
3. 删除 `GET /admin` 后,当前服务访问该路径应返回 `404`
### 4.3 数据库信息来源
@@ -129,7 +134,7 @@ claims 设计:
## 6. 后台页面设计
首版页面包含三个主区域:
本节已由独立后台前端工程方案接管。历史同源页面包含三个主区域:
1. 登录卡片。
2. 数据库概览面板。
@@ -149,47 +154,11 @@ claims 设计:
2. 当前 `SpacetimeDB server/database` 配置。
3. `SpacetimeDB` 数据库基础信息。
4. 当前 schema 表清单。
5. 首批关键表的行数统计。
5. schema 表清单对应的逐表行数统计。
首批关键表固定覆盖:
表统计必须以 SpacetimeDB schema 返回的表名为唯一来源,`schemaTableNames` 的数量必须与 `tableStats` 的行数一致。后台服务只对 schema 中符合安全 SQL 标识符格式的表名发起 `SELECT COUNT(*)`,不提供任意 SQL 输入能力。
1. `runtime_setting`
2. `runtime_snapshot`
3. `user_browse_history`
4. `profile_dashboard_state`
5. `profile_wallet_ledger`
6. `profile_played_world`
7. `profile_save_archive`
8. `story_session`
9. `story_event`
10. `battle_state`
11. `inventory_slot`
12. `quest_record`
13. `quest_log`
14. `treasure_record`
15. `npc_state`
16. `custom_world_profile`
17. `custom_world_gallery_entry`
18. `custom_world_agent_session`
19. `custom_world_agent_message`
20. `custom_world_agent_operation`
21. `custom_world_draft_card`
22. `big_fish_creation_session`
23. `big_fish_agent_message`
24. `big_fish_asset_slot`
25. `big_fish_runtime_run`
26. `puzzle_work_profile`
27. `puzzle_agent_session`
28. `puzzle_agent_message`
29. `puzzle_runtime_run`
30. `ai_task`
31. `ai_task_stage`
32. `ai_text_chunk`
33. `ai_result_reference`
34. `asset_object`
35. `asset_entity_binding`
返回中的计数失败项必须带错误信息,不能静默吞掉。
返回中的计数失败项必须带错误信息不能静默吞掉。SpacetimeDB private 表或当前身份不可见的表可能在 `/sql` 下返回 `no such table` / `marked private`这类项统一展示为“不可统计private 或当前身份不可见)”,不作为整页读取失败处理。
## 8. API 调试设计
@@ -223,7 +192,7 @@ claims 设计:
默认策略:
1. 若未配置用户名或密码,则后台登录接口返回 `503`后台页面显示“后台未启用
1. 若未配置用户名或密码,则后台登录接口返回 `503`独立后台前端自行展示未启用状态
2. 默认管理员 token TTL 为 `4` 小时。
## 10. 测试要求
@@ -240,21 +209,27 @@ claims 设计:
## 11. 路由清单
首版新增路由:
当前保留的管理 API 路由:
1. `GET /admin`
2. `POST /admin/api/login`
3. `GET /admin/api/me`
4. `GET /admin/api/overview`
5. `POST /admin/api/debug/http`
1. `POST /admin/api/login`
2. `GET /admin/api/me`
3. `GET /admin/api/overview`
4. `POST /admin/api/debug/http`
5. `POST /admin/api/profile/redeem-codes`
6. `POST /admin/api/profile/redeem-codes/disable`
`GET /admin` 已取消挂载,后续由独立后台前端工程承接页面入口。
## 12. 完成定义
满足以下条件时,本任务视为完成:
当前管理 API 保留与内嵌页面移除满足以下条件时,本任务视为完成:
1. `api-server` 内存在受保护后台管理域。
2. 管理员用户名密码可登录。
3. 普通用户 token 无法访问后台接口。
4. 后台能看到服务和数据库真实概览。
5. 后台能调试当前服务 HTTP 接口。
6. 路由索引与技术文档已同步更新。
6. 兑换码管理 API 可由管理员 token 调用。
7. `GET /admin` 不再挂载,访问返回 `404`
8. 独立后台前端 PRD 与技术方案已补齐。
9. 路由索引与技术文档已同步更新。

View File

@@ -0,0 +1,454 @@
# 后台管理独立前端工程技术方案
日期:`2026-04-30`
对应 PRD[后台管理独立前端工程 PRD](../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md)
落地状态:`2026-04-30` 已创建 `apps/admin-web` 独立前端工程包含登录、总览、API 调试、兑换码管理和注册邀请码管理首版页面;根工程已补 `admin-web:*` 转发脚本。`2026-05-01` 起,根构建与 Ubuntu 发布包会同步构建后台前端,并在发布包 Web 网关中以同域 `/admin/` 暴露。
## 1. 结论
后台管理端采用独立前端工程,路径固定为 `apps/admin-web`。它只负责 UI 表现、输入采集、请求发起和结果渲染所有鉴权、聚合、写操作、SpacetimeDB 访问和业务校验继续收口在 `server-rs/crates/api-server`
本方案接管旧 `api-server` 内嵌 HTML/CSS/JS 页面Rust `api-server` 直连时旧 `GET /admin` 不再挂载。部署态后台入口由发布包内 `web-server.mjs` 承接:`/admin/` 返回独立前端静态产物,`/admin/api/*` 继续反代到 `api-server`
## 2. 工程结构
建议首版结构:
```text
apps/
└─ admin-web/
├─ index.html
├─ package.json
├─ tsconfig.json
├─ vite.config.ts
└─ src/
├─ main.tsx
├─ app/
│ ├─ AdminApp.tsx
│ ├─ AdminShell.tsx
│ └─ adminRoutes.ts
├─ api/
│ ├─ adminApiClient.ts
│ └─ adminApiTypes.ts
├─ auth/
│ └─ adminAuthStore.ts
├─ pages/
│ ├─ AdminLoginPage.tsx
│ ├─ AdminOverviewPage.tsx
│ ├─ AdminDebugHttpPage.tsx
│ ├─ AdminRedeemCodePage.tsx
│ └─ AdminInviteCodePage.tsx
└─ styles/
└─ admin.css
```
首版可使用独立 `package.json`,不要求立刻把根工程改成 npm workspace。后续如果根工程统一 workspace再把 `apps/admin-web` 纳入统一脚本。
## 3. 技术栈
1. React + TypeScript + Vite。
2. 图标使用 `lucide-react`
3. 样式首版使用普通 CSS 或 CSS Modules不引入新的大型 UI 组件库。
4. 请求使用浏览器 `fetch` 封装,不新增状态管理库。
5. 不引入 SpacetimeDB TypeScript SDK管理端不直连 SpacetimeDB。
## 4. API 边界
### 4.1 基础约定
所有管理端请求使用同一个 `adminApiClient`
1. base URL 由 `VITE_ADMIN_API_BASE_URL` 配置。
2. 未配置时默认同源空前缀。
3. 有 token 时附加 `Authorization: Bearer <token>`
4. 后端统一响应 envelope 时,前端读取 `data`;错误优先读取 `error.details.message`,再读 `error.message`,最后回退到 HTTP 状态。
前端统一按以下响应形状解析,不在页面组件里重复拆 envelope
```ts
export interface ApiSuccessEnvelope<T> {
data: T;
meta?: unknown;
}
export interface ApiErrorEnvelope {
error?: {
code?: string;
message?: string;
details?: {
message?: string;
[key: string]: unknown;
} | null;
};
meta?: unknown;
}
```
`adminApiClient` 暴露 `request<T>()``get<T>()``post<T>()` 三层即可。页面只拿到成功数据或抛出的中文错误消息,不直接处理 `Response`
### 4.2 已有管理接口
| 功能 | 方法与路径 | 鉴权 |
| --- | --- | --- |
| 管理员登录 | `POST /admin/api/login` | 无 |
| 当前管理员 | `GET /admin/api/me` | 管理员 Bearer |
| 服务与数据库概览 | `GET /admin/api/overview` | 管理员 Bearer |
| 受控 HTTP 调试 | `POST /admin/api/debug/http` | 管理员 Bearer |
| 创建/更新兑换码 | `POST /admin/api/profile/redeem-codes` | 管理员 Bearer |
| 停用兑换码 | `POST /admin/api/profile/redeem-codes/disable` | 管理员 Bearer |
| 创建/更新注册邀请码 | `POST /admin/api/profile/invite-codes` | 管理员 Bearer |
### 4.3 前端类型命名
后台前端首版不引入自动生成 contract。为了避免字段漂移`apps/admin-web/src/api/adminApiTypes.ts` 必须按 `shared-contracts` 的 camelCase JSON 字段命名:
```ts
export interface AdminSessionPayload {
subject: string;
username: string;
displayName: string;
roles: string[];
issuedAt: string;
expiresAt: string;
}
export interface AdminLoginResponse {
token: string;
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 interface AdminDebugHttpRequest {
method: string;
path: string;
headers?: AdminDebugHeaderInput[];
body?: string;
}
export interface AdminDebugHttpResponse {
status: number;
statusText: string;
headers: AdminDebugHeaderInput[];
bodyText: string;
bodyJson: unknown | null;
}
```
兑换码类型同样保持 camelCase
```ts
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 AdminDisableProfileRedeemCodeRequest {
code: string;
}
export interface AdminUpsertProfileInviteCodeRequest {
inviteCode: string;
metadata?: Record<string, unknown>;
}
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<string, unknown>;
createdAt: string;
updatedAt: string;
}
```
### 4.4 登录 contract
请求:
```json
{
"username": "root",
"password": "secret123"
}
```
成功数据:
```json
{
"token": "<admin bearer token>",
"admin": {
"subject": "admin:root",
"username": "root",
"displayName": "root",
"roles": ["admin"],
"issuedAt": "2026-04-30T00:00:00Z",
"expiresAt": "2026-04-30T04:00:00Z"
}
}
```
`503` 表示后台未启用;`401` 表示用户名或密码错误。
### 4.5 总览 contract
`GET /admin/api/overview` 返回:
1. `service``bindHost``bindPort``jwtIssuer``adminEnabled``spacetimeServerUrl``spacetimeDatabase`
2. `database``databaseIdentity``ownerIdentity``hostType``schemaTableNames``tableStats``fetchErrors`
后端读取 SpacetimeDB schema 时必须请求 `/v1/database/{database}/schema?version=9`。SpacetimeDB 2.x schema HTTP API 缺少 `version` query 会返回 `400 missing field version`,后台页面只能展示读取异常,不能拿到真实表名。
`schemaTableNames``tableStats` 必须采用同一份 schema 表清单生成不能再用硬编码关键表白名单补齐统计项。后台右上角显示的表数量必须等于统计表格实际行数schema 读取失败时两者均为空,并通过 `fetchErrors` 暴露读取失败原因。
后端读取表行数时必须按 SpacetimeDB 2.x `/sql` 响应解析:接口返回 statement result 数组,单条结果内的 `schema.elements` 描述列名,`rows` 是按列顺序排列的数组行,例如 `rows: [[0]]`。后台服务不能再假设响应是 `{ rows: [{ row_count: 0 }] }` 的对象行形状;为了兼容小版本差异,可保留对象行兜底解析。
`tableStats` 中单表失败必须展示 `errorMessage`不能让整页变成空白。SpacetimeDB private 表或当前身份不可见的表在 `/sql` 下可能返回 `no such table` / `marked private`后台服务必须将这类错误归一为“不可统计private 或当前身份不可见)”,避免把预期的访问边界展示成原始 HTTP 400 故障。
线上如果大量表都显示“不可统计private 或当前身份不可见)”,优先检查 `api-server` 启动环境中的 `GENARRATIVE_SPACETIME_TOKEN` / `GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN` 是否存在且属于目标库 owner。Jenkins 覆盖发布包时必须保留部署目录已有运行 token只带迁移 token 不能让后台概览读取 private 表。
### 4.6 API 调试 contract
请求:
```json
{
"method": "GET",
"path": "/healthz",
"headers": [],
"body": ""
}
```
限制由后端执行:
1. `path` 只允许同源相对路径。
2. 禁止绝对 URL。
3. 禁止调试 `/admin/api/login`
4. 禁止覆盖危险请求头。
5. 请求体大小和超时由后端收口。
### 4.7 兑换码管理 contract
创建/更新请求:
```json
{
"code": "WELCOME2026",
"mode": "public",
"rewardPoints": 100,
"maxUses": 1,
"enabled": true,
"allowedUserIds": [],
"allowedPublicUserCodes": []
}
```
停用请求:
兑换码管理页的最近一次接口返回记录由 `AdminApp` 维护为管理端会话态,并传入 `AdminRedeemCodePage` 渲染。页面页签通过 hash 切换时子页面会卸载,不能把最近记录只放在兑换码页面内部 `useState` 中,否则切换到其他页签再返回会展示“暂无记录”。该会话态只用于保留当前操作结果,不作为兑换码历史列表;退出登录或重新登录时清空。
```json
{
"code": "WELCOME2026"
}
```
成功返回兑换码记录:
```json
{
"code": "WELCOME2026",
"mode": "public",
"rewardPoints": 100,
"maxUses": 1,
"globalUsedCount": 0,
"enabled": true,
"allowedUserIds": [],
"createdBy": "admin:root",
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
}
```
前端只做基础输入约束,最终标准化、私有码用户解析、次数和奖励合法性以 `server-rs` 为准。
### 4.8 邀请码管理 contract
创建/更新请求:
```json
{
"inviteCode": "SPRING2026",
"metadata": {
"batch": "spring"
}
}
```
成功返回邀请码记录:
```json
{
"userId": "admin",
"inviteCode": "SPRING2026",
"metadata": {
"batch": "spring"
},
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
}
```
邀请码页的 metadata 输入必须先在前端解析为 JSON 对象;空字符串按 `{}` 处理,数组、字符串、数字等非对象值直接提示错误。最终标准化、长度限制和邀请码合法性以 `server-rs` 为准。
## 5. 鉴权与会话
1. token key 固定为 `genarrative_admin_token`
2. token 首版存 localStorage。
3. 应用启动时如果存在 token先调用 `GET /admin/api/me`
4. `401` 时清空 token 并回到登录页。
5. `403` 时展示无权限状态,不自动重试。
6. 退出登录只清理本地 token首版没有后台 refresh session 和服务端会话吊销。
## 6. 页面实现要点
1. `AdminShell` 承载导航、当前管理员、退出按钮和页面容器。
2. 登录页不进入 `AdminShell`,避免未登录时展示后台导航。
3. 总览页加载失败时展示后端错误,不吞掉 `fetchErrors`
4. API 调试页的 headers 使用键值行编辑,提交前转为 `[{ name, value }]`
5. 兑换码页的 `mode=private` 时展示允许用户输入区;其他模式提交空数组。
6. 邀请码页只提交 `inviteCode` 与 JSON 对象 metadata不在前端复制后端邀请码规则。
7. 所有按钮的 loading 状态必须锁定重复提交。
8. 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动。
## 7. 部署与联调
### 7.1 本地联调
1. 启动后端:`npm run api-server:maincloud`
2. 启动后台前端:在 `apps/admin-web` 执行 `npm run dev`
3. 后台 dev server 通过 Vite proxy 转发 `/admin/api``ADMIN_API_TARGET`;未配置时默认 `http://127.0.0.1:3100`
4. 若使用非 3100 端口,在仓库根目录 `.env.local` 设置 `ADMIN_API_TARGET=http://127.0.0.1:<api-server-port>`,并重启后台前端 dev server。
5. `GENARRATIVE_API_PORT` 控制 Rust `api-server` 监听端口;`ADMIN_API_TARGET` 只控制后台前端 dev proxy 目标,二者需要指向同一个端口。
### 7.2 构建部署
当前发布形态固定为同域 `/admin/`
1. 本地单独执行 `npm run admin-web:build` 时,后台构建产物默认输出到 `apps/admin-web/dist`
2. 根工程执行 `npm run build` 时,会先构建主前端,再构建后台前端;任一构建失败或输出 warning 都会让构建门禁失败。
3. Ubuntu 发布包执行 `npm run deploy:rust:remote` 时,后台前端以 Vite `--base /admin/` 构建到发布包 `web/admin/`
4. 发布包 `web-server.mjs``/admin` 返回 301 到 `/admin/`,对 `/admin/``/admin/*` 提供后台 SPA fallback`/admin/api/*` 优先反代到 `api-server`
该形态不新增后台静态端口和后台专用后端。`server-rs` 仍然是唯一管理 API 后端,后台前端不直连 SpacetimeDB。
### 7.3 后台工程脚本
`apps/admin-web/package.json` 首版至少提供以下脚本:
```json
{
"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"
}
}
```
如果后续接入根 npm workspace再在根 `package.json` 增加转发脚本;本轮不要为了后台工程强行重排现有前端脚本。
当前工程没有启用 npm workspace因此后台构建脚本必须从仓库根目录调用 root toolchain。`scripts/admin-web-build.mjs` 统一执行 `tsc --noEmit -p apps/admin-web/tsconfig.json` 与 Vite 构建,避免 `npm --prefix apps/admin-web` 在子目录找不到 `tsc`
当前根工程同步提供以下转发脚本:
1. `npm run admin-web:dev`
2. `npm run admin-web:typecheck`
3. `npm run admin-web:build`
4. `npm run admin-web:preview`
## 8. 测试计划
1. `apps/admin-web`
- 登录成功和失败。
- token 恢复、过期清理、退出登录。
- 总览页正常数据、部分表统计失败、整体请求失败。
- API 调试成功访问 `/healthz`,绝对 URL 被后端拒绝。
- 兑换码 public/unique/private 表单提交和停用。
- 邀请码表单提交、metadata JSON 对象校验和结果展示。
2. 根工程:
- `npm run check:encoding`
- 后续接入根 workspace 后,补充后台工程 build/typecheck 脚本。
3. 后端:
- 继续保留 `cargo test -p api-server --manifest-path server-rs/Cargo.toml admin`
- 修改后端管理 API 后必须运行 `npm run api-server:maincloud` 并手动验证 `/admin` 为 404、`/admin/api/login` 可用。
## 9. 后续扩展边界
后续新增用户管理、作品审核、资产审核、订单/充值管理时,必须先补对应 PRD 和技术方案,并在 `server-rs` 增加受保护管理 API。不要让 `apps/admin-web` 直接读取 SpacetimeDB 或复制业务规则。
## 10. 实施顺序
1. 先创建 `apps/admin-web` 工程骨架,确保空应用可 `dev/build`
2. 再实现 `adminApiTypes``adminApiClient`,用 `/admin/api/login` 做第一条真实链路。
3. 接入 `adminAuthStore` 和启动恢复逻辑,确认 `401` 会清理本地 token。
4. 完成 `AdminShell` 与四页路由再分别接入总览、API 调试、兑换码和邀请码接口。
5. 最后补测试、运行 `npm run check:encoding`,并确认 `GET /admin` 仍由 `api-server` 返回 `404`
当前实现已完成第 1 至第 4 步。验证以实际命令输出为准。

View File

@@ -45,7 +45,7 @@
修复:
1.`map_password_entry_error(...)` 中补充 `InvalidPublicUserCode`
2. 返回中文错误文案 `叙世号格式不正确`
2. 返回中文错误文案 `百梦号格式不正确`
### 3.3 `module-custom-world` 的 `Display` 分支未覆盖新字段错误

View File

@@ -1,8 +1,8 @@
# 资产操作叙世币消耗接入方案
# 资产操作光点消耗接入方案
## 背景
当前叙世币钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成和作品发布由 Axum API 调用外部模型或写入业务状态SpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此计费需要拆成两层:
当前光点钱包余额、充值流水与邀请奖励已经收口到 `server-rs/crates/spacetime-module/src/runtime/profile.rs`。资产图片生成和作品发布由 Axum API 调用外部模型或写入业务状态SpacetimeDB reducer/procedure 不能直接执行外部网络生成,因此计费需要拆成两层:
- SpacetimeDB 负责钱包余额和流水的原子变更。
- Axum 资产操作服务负责在执行业务资产操作前扣费,并在生成、持久化或发布失败时补偿退款。
@@ -24,13 +24,13 @@
暂不接入以下入口:
- 旧资产工坊角色主形象/动作生成接口:当前仍使用 `asset-tool` 作为兼容归属,无法确认真实用户。
- 手动上传封面:不调用外部生成模型,不消耗叙世币
- 手动上传封面:不调用外部生成模型,不消耗光点
- 自定义世界草稿自动补图链路:属于后台补全流程,避免一次用户操作触发多笔不可预期扣费。
- 文本实体、NPC 生成:本次需求聚焦图片资产和发布资产操作,首期只覆盖可明确归属的入口。
## 计费规则
- 每次可计费资产操作消耗 `1`叙世币
- 每次可计费资产操作消耗 `1`光点
- 图片生成和作品发布都按资产操作计费;余额不足时禁止继续执行。
- 在调用外部图片生成或发布 mutation 前预扣,余额不足时直接返回业务错误,不继续调用后续资产操作。
- 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,资产操作服务自动发起同额退款。

View File

@@ -106,3 +106,12 @@
2. 响应字段命名与前端约定一致
3. 配置开关可稳定映射到返回数组
4. 文档、任务清单与测试已同步更新
## 8. 2026-05-01 前端降级修复记录
本地联调时若 `api-server` 未启动或 Vite 代理暂时返回 `500``GET /api/auth/login-options` 会失败。前端必须继续遵循第 5.3 节约束:
1. `AuthGate``login-options` 读取失败时设置 `availableLoginMethods = ["password"]`
2. 该失败只代表登录方式配置探测失败,不代表登录功能不可用,因此不把 `读取登录方式失败` 写入登录弹窗错误条。
3. 登录弹窗仍展示密码登录表单,玩家可继续登录后进入创作链路。
4. 本地仍需要启动 `api-server`,否则后续 `POST /api/auth/entry` 等真实登录请求无法完成。

View File

@@ -0,0 +1,63 @@
# 新账号短信登录后置邀请码弹窗设计
日期:`2026-05-01`
## 1. 目标
账号入口不再展示独立注册入口。用户统一从短信登录进入,后端通过 `POST /api/auth/phone/login` 返回的 `created` 字段判断本次是否创建了新账号。
`created=true` 时,前端在登录成功后额外弹出独立邀请码面板:
1. 标题固定为 `请填写邀请码`
2. 标题下方展示邀请码输入框。
3. 输入为空时主按钮显示 `跳过`,点击后关闭面板。
4. 输入非空时主按钮显示 `提交`,点击后提交邀请码。
5. 面板右上角提供取消按钮,点击后关闭面板。
## 2. 入口调整
登录弹窗只保留可用登录方式:
1. 短信登录。
2. 密码登录。
3. 微信登录。
不得再展示 `注册` 页签、注册按钮或注册表单。邀请码不再出现在短信验证码表单中,避免用户把登录和注册理解成两套入口。
## 3. 邀请码提交
后置弹窗提交邀请码时调用已登录接口:
```text
POST /api/profile/referrals/redeem-code
```
请求体:
```json
{
"inviteCode": "SPRING2026"
}
```
后端继续使用 SpacetimeDB 的 `redeem_profile_referral_invite_code` procedure 作为唯一真相源。该 procedure 已负责校验:
1. 每个用户最多只能填写一个邀请码。
2. 邀请码必须存在。
3. 用户不能填写自己的邀请码。
4. 双方奖励与钱包流水在同一事务内落地。
## 4. URL 邀请码
若地址中存在 `inviteCode``invite_code`,前端只将其作为新账号后置弹窗的默认输入值。它不会触发注册页签,也不会在短信登录请求中提前提交。
若用户登录的是已有账号,则不会弹出新账号邀请码面板。
## 5. 完成定义
1. 登录弹窗内不可见注册入口。
2. 短信登录创建新账号后弹出邀请码面板。
3. 邀请码为空时按钮为 `跳过`,非空时按钮为 `提交`
4. 取消按钮可关闭面板。
5. 已登录邀请码接口允许提交,并继续由 SpacetimeDB procedure 兜底业务校验。
6. 前端测试覆盖注册入口删除、新账号弹窗、URL 邀请码预填与提交。

View File

@@ -0,0 +1,105 @@
# 认证快照同步与抓大鹅本地联调修复记录
日期:`2026-05-01`
## 1. 现场问题
本地访问 `http://127.0.0.1:3000` 时出现两类失败:
1. 验证码登录成功后,接口返回 `同步认证快照失败`
2. 抓大鹅创作页请求报 `Failed to initiate WebSocket connection ... HTTP error: 503 Service Unavailable`,或同源创作接口直接 `404`
## 2. 根因
### 2.1 Maincloud 目标库挂起
CLI 直接查询 `xushi-p4wfr` 返回:
```text
Error: database is suspended
HTTP status server error (503 Service Unavailable)
```
这说明 `maincloud.spacetimedb.com` 入口在线,但具体数据库 `xushi-p4wfr` 当前不可订阅、不可查 schema、不可执行 SQL。所有依赖该库的 procedure 都会失败。
### 2.2 认证快照同步被当成硬失败
手机号、密码、刷新、退出等认证流程会先更新本地 `auth_store`,然后调用 SpacetimeDB 同步认证快照。旧逻辑把同步失败直接转为 HTTP 500导致本地会话已经创建成功响应却被远端快照同步失败阻断。
### 2.3 Vite 未代理 `/api/creation`
抓大鹅创作接口挂在:
```text
/api/creation/match3d/*
```
但 Vite 代理只覆盖了 `/api/auth``/api/runtime` 等路径,未覆盖 `/api/creation`,因此浏览器同源请求会被 Vite 返回 `404`,没有进入 Rust `api-server`
## 3. 修复
### 3.1 认证快照同步改为非阻断
`AppState::sync_auth_store_snapshot_to_spacetime` 保持导出本地快照、写入 SpacetimeDB、导入正式表的顺序但当远端写入或导入失败时只写 warn 日志并返回 `Ok(())`
设计边界:
1. 当前认证请求的即时真相源是本地 `auth_store`
2. SpacetimeDB 认证快照用于跨进程恢复和正式表投影。
3. 远端库挂起或网络异常只降级远端恢复能力,不回滚已经成功的登录、刷新、退出和资料更新。
### 3.2 Vite 补齐创作接口代理
`vite.config.ts` 新增:
```ts
'/api/creation': {
target: runtimeServerTarget,
changeOrigin: true,
secure: false,
},
```
前端仍只请求同源 `/api/creation/match3d/*`,不直连 Rust 端口。
## 4. 本地可跑链路
Maincloud `xushi-p4wfr` 挂起期间,抓大鹅本地体验应使用本地 SpacetimeDB
```powershell
spacetime --root-dir=server-rs/.spacetimedb/local start --edition standalone --listen-addr 127.0.0.1:3101
$env:GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="codex-local-bootstrap-secret-20260501"
spacetime --root-dir=server-rs/.spacetimedb/local publish xushi-p4wfr --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module -c=on-conflict --yes
```
再让 Rust API 指向本地库:
```powershell
$env:GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="http://127.0.0.1:3101"
$env:GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr"
$env:GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN=""
npm run api-server:maincloud
```
最后重启前端:
```powershell
$env:RUST_SERVER_TARGET="http://127.0.0.1:3100"
$env:GENARRATIVE_RUNTIME_SERVER_TARGET="http://127.0.0.1:3100"
npm run dev:web
```
## 5. 验证结果
已验证:
1. `GET http://127.0.0.1:3000/api/auth/login-options` 返回 `["phone","password"]`
2. `GET http://127.0.0.1:3000/api/runtime/match3d/gallery` 返回 `{"items":[]}`,不再返回 SpacetimeDB 503。
3. 未登录请求 `POST http://127.0.0.1:3000/api/creation/match3d/sessions` 返回 `401`,说明同源请求已进入 Rust 鉴权层,不再被 Vite `404`
4. 隔离端口指向挂起的 Maincloud 并使用 mock 短信时,手机号验证码登录返回 `200` 和 token日志只记录“认证快照写入 SpacetimeDB 失败,当前认证流程继续”。
## 6. 后续
1. Maincloud `xushi-p4wfr` 仍需恢复数据库挂起状态,否则正式云端玩法 procedure 仍不可用。
2. 本地开发如只为体验抓大鹅,可继续使用本地 SpacetimeDB 链路。
3. 认证快照同步失败会影响进程重启后的云端恢复完整性,需要在 Maincloud 恢复后重新完成一次成功同步。

View File

@@ -34,7 +34,7 @@ Stage 1 已把 Rust 鉴权快照同步到 SpacetimeDB 的 `auth_store_snapshot`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | `String` | 主键。 |
| `public_user_code` | `String` | 公开叙世号。 |
| `public_user_code` | `String` | 公开百梦号。 |
| `username` | `String` | 当前账号用户名。 |
| `display_name` | `String` | 展示名。 |
| `phone_number_masked` | `Option<String>` | 脱敏手机号。 |

View File

@@ -47,7 +47,7 @@ server-rs/crates/api-server/src/prompt/big_fish.rs
同时把 `prompt/mod.rs` 补齐为正式导出入口,和现有:
1. `puzzle_image.rs`
1. `puzzle/image.rs`
2. `character_visual.rs`
3. `character_animation.rs`
4. `scene_background.rs`

View File

@@ -37,7 +37,7 @@
- 当前场景的核心任务描述。
- 文本会作为游戏中首次进入某个场景生成章节任务的关键上下文。
- 必须结合场景描述、场景入口钩子、出场角色与 3 幕事件,说明玩家首次进入该场景时要完成什么。
- 世界档案的场景详情页必须直接展示该字段,便于创作者确认每个场景的默认章节任务。
- 世界档案的场景详情页必须直接展示该字段,便于百梦主确认每个场景的默认章节任务。
### Landmark 生成源字段

View File

@@ -16,22 +16,23 @@
## 页面路径表
| 页面阶段 | 路径 | 说明 |
| --- | --- | --- |
| `platform` | `/` | 平台首页、广场、我的、创作中心等主入口 |
| `detail` | `/worlds/detail` | RPG 世界详情页,依赖当前已选作品 |
| `agent-workspace` | `/creation/rpg/agent` | RPG Agent 共创工作区 |
| `custom-world-generating` | `/creation/rpg/generating` | RPG 世界草稿生成进度页 |
| `custom-world-result` | `/creation/rpg/result` | RPG 世界结果页与编辑页 |
| `big-fish-agent-workspace` | `/creation/big-fish/agent` | 大鱼吃小鱼 Agent 共创工作区 |
| `big-fish-result` | `/creation/big-fish/result` | 大鱼吃小鱼草稿结果页 |
| `big-fish-runtime` | `/runtime/big-fish` | 正式链路中的大鱼吃小鱼运行页 |
| `puzzle-agent-workspace` | `/creation/puzzle/agent` | 拼图 Agent 共创工作区 |
| `puzzle-result` | `/creation/puzzle/result` | 拼图草稿结果页 |
| `puzzle-gallery-detail` | `/gallery/puzzle/detail` | 拼图作品详情页,依赖当前已选作品 |
| `puzzle-runtime` | `/runtime/puzzle` | 正式链路中的拼图运行页 |
| RPG 选角页 | `/runtime/rpg/characters` | 进入世界后、确认角色前的选角阶段 |
| RPG 冒险页 | `/runtime/rpg/adventure` | 已确认角色后的 RPG 主运行态 |
| 页面阶段 | 路径 | 说明 |
| -------------------------- | --------------------------- | ------------------------------------------------------ |
| `platform` | `/` | 平台首页、广场、我的、创作中心等主入口 |
| `work-detail` | `/works/detail` | 统一公开作品详情页,承接 RPG、拼图、大鱼吃小鱼公开作品 |
| `detail` | `/worlds/detail` | RPG 世界详情页,依赖当前已选作品 |
| `agent-workspace` | `/creation/rpg/agent` | RPG Agent 共创工作区 |
| `custom-world-generating` | `/creation/rpg/generating` | RPG 世界草稿生成进度页 |
| `custom-world-result` | `/creation/rpg/result` | RPG 世界结果页与编辑页 |
| `big-fish-agent-workspace` | `/creation/big-fish/agent` | 大鱼吃小鱼 Agent 共创工作区 |
| `big-fish-result` | `/creation/big-fish/result` | 大鱼吃小鱼草稿结果页 |
| `big-fish-runtime` | `/runtime/big-fish` | 正式链路中的大鱼吃小鱼运行页 |
| `puzzle-agent-workspace` | `/creation/puzzle/agent` | 拼图 Agent 共创工作区 |
| `puzzle-result` | `/creation/puzzle/result` | 拼图草稿结果页 |
| `puzzle-gallery-detail` | `/gallery/puzzle/detail` | 拼图作品详情页,依赖当前已选作品 |
| `puzzle-runtime` | `/runtime/puzzle` | 正式链路中的拼图运行页 |
| RPG 选角页 | `/runtime/rpg/characters` | 进入世界后、确认角色前的选角阶段 |
| RPG 冒险页 | `/runtime/rpg/adventure` | 已确认角色后的 RPG 主运行态 |
## 落地边界

View File

@@ -16,15 +16,19 @@
1. 构建产物目录统一使用 `build/<版本号>/`
2. 默认使用 Jenkins `BUILD_NUMBER` 作为版本号,避免依赖时间戳;如有需要也允许显式传 `BUILD_VERSION`
3. `构建``构建并部署``checkout scm` 后、实际构建前必须执行 `git reset --hard HEAD``git clean -fd`,避免固定源码目录内的 Git 变更和未跟踪文件影响发布包;不使用 `-x`,避免删除 `node_modules/` 等忽略目录后与 `RUN_NPM_CI=false` 冲突。
4. `部署` 流水线允许人工启动;没有上游触发 cause 时按人工部署处理,不再直接失败
5. `部署` 流水线仅在存在上游触发 cause 时校验上游作业名与传入的 `EXPECTED_UPSTREAM_JOB` 一致;如配置了环境变量 `GENARRATIVE_ALLOWED_UPSTREAM_JOB`,还必须与该值一致
6. `构建并部署` 在触发 `部署` 前先释放自己的构建节点,避免单执行器节点出现死锁
7. `部署` 不重新构建,不重新上传,不从 Jenkins 插件仓库复制产物,直接使用上游构建节点的本地 `build/<版本号>/` 目录
8. `部署` 流水线读取触发原因时必须使用 `currentBuild.getBuildCauses(...)` 这类白名单方法,不能直接访问 `currentBuild.rawBuild`,否则会被 Jenkins Script Security 拦截
9. 由于 Jenkins Pipeline 的 `build` 步骤触发下游时,原因类型通常是 `org.jenkinsci.plugins.workflow.support.steps.build.BuildUpstreamCause`,实现上需要同时兼容它和经典的 `hudson.model.Cause$UpstreamCause`,否则会把真实的上游触发误判成人工执行
10. 如果线上进程的启停必须经过 `sudo`,只允许 `start.sh` / `stop.sh` 这两个 hook 使用 `sudo -n` 执行,部署目录清空与文件覆盖仍保持普通权限
11. `WEB_PORT` 必须在 `构建并部署``部署` 两条流水线之间使用同名参数传递;部署脚本会把最终端口写入固定部署目录 `.env.local``GENARRATIVE_WEB_PORT`,避免 `sudo` 启动 hook 时环境变量被清理导致端口回退
3. 所有使用仓库源码的 Jenkins 流水线在实际执行脚本前必须执行 `git reset --hard HEAD`,避免固定源码目录内的 Git 变更影响本次构建、部署或迁移操作;其中 `构建``构建并部署` 在实际构建前还必须执行 `git clean -fd` 清理未跟踪文件,不使用 `-x`,避免删除 `node_modules/` 等忽略目录后与 `RUN_NPM_CI=false` 冲突。
4. `构建并部署` 可选填写 `COMMIT_HASH`。留空时使用 Jenkins SCM 当前检出的提交;填写时只能是 7 到 40 位十六进制 commit hash流水线会先按 SCM checkout 得到仓库,再尽量拉取 `origin` 全部分支引用、解析该 hash 并 detached checkout 到对应 commit 后构建
5. `部署` 流水线允许人工启动;没有上游触发 cause 时按人工部署处理,不再直接失败
6. `部署` 流水线仅在存在上游触发 cause 时校验上游作业名与传入的 `EXPECTED_UPSTREAM_JOB` 一致;如配置了环境变量 `GENARRATIVE_ALLOWED_UPSTREAM_JOB`,还必须与该值一致
7. `构建并部署` 在触发 `部署` 前先释放自己的构建节点,避免单执行器节点出现死锁
8. `部署` 不重新构建,不重新上传,不从 Jenkins 插件仓库复制产物,直接使用上游构建节点的本地 `build/<版本号>/` 目录
9. `部署` 流水线读取触发原因时必须使用 `currentBuild.getBuildCauses(...)` 这类白名单方法,不能直接访问 `currentBuild.rawBuild`,否则会被 Jenkins Script Security 拦截
10. 由于 Jenkins Pipeline 的 `build` 步骤触发下游时,原因类型通常是 `org.jenkinsci.plugins.workflow.support.steps.build.BuildUpstreamCause`,实现上需要同时兼容它和经典的 `hudson.model.Cause$UpstreamCause`,否则会把真实的上游触发误判成人工执行
11. 如果线上进程的启停必须经过 `sudo`,只允许 `start.sh` / `stop.sh` 这两个 hook 使用 `sudo -n` 执行,部署目录清空与文件覆盖仍保持普通权限
12. `WEB_PORT` 必须在 `构建并部署``部署` 两条流水线之间使用同名参数传递;部署脚本会把最终端口写入固定部署目录 `.env.local``GENARRATIVE_WEB_PORT`,避免 `sudo` 启动 hook 时环境变量被清理导致端口回退。
13. `DATABASE` 必须匹配 SpacetimeDB CLI 数据库名规则 `^[a-z0-9]+(-[a-z0-9]+)*$`:只能使用小写字母、数字,并用单个短横线分隔;大写字母、点号、下划线、首尾短横线和连续短横线都会被拒绝,否则 `spacetime publish` 会报 `invalid characters in database name`
14. Jenkins 日志必须能看到构建参数中的 SpacetimeDB 发布数据库,以及 `start.sh` 最终加载环境文件后的运行时数据库、server 和 root-dir避免 `.env.local` 覆盖默认值后无法判断实际发布目标。
15. `构建并部署` 流水线开头通过 `GENARRATIVE_TOOLS_PATH` 固定声明 Jenkins 用户下的 Node、Cargo、SpacetimeDB 常用安装目录:`/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin:/var/lib/jenkins/.cargo/bin:/var/lib/jenkins/.local/bin:/var/lib/jenkins/bin`,并显式保留 `/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`,避免覆盖系统路径导致 `sh` 步骤无法启动。
## 3. 节点与工作区要求
@@ -80,7 +84,8 @@ jenkins/Jenkinsfile.deploy
1. 读取触发原因;人工启动时跳过上游门禁,上游触发时同时兼容 `BuildUpstreamCause` 与经典 `UpstreamCause` 并继续校验上游作业名。
2. 校验 `BUILD_VERSION``SOURCE_WORKSPACE_ROOT``DEPLOY_DIRECTORY` 非空。
3. 执行:
3. `SOURCE_WORKSPACE_ROOT` 内执行 `git reset --hard HEAD`,确保部署脚本和构建产物选择不受本地改动影响。
4. 执行:
```bash
scripts/jenkins-deploy-release.sh \
@@ -88,20 +93,24 @@ scripts/jenkins-deploy-release.sh \
--deploy-dir /var/lib/jenkins/deploy/Genarrative \
--web-port <WEB_PORT> \
[--clear-database] \
[--no-migrate-on-conflict] \
[--migration-dir <MIGRATION_DIRECTORY>] \
--hook-with-sudo
```
脚本语义:
1. 若部署目录已有旧版本且存在 `stop.sh`,先执行旧版本 `stop.sh`
2. 只删除发布产物白名单中的旧文件,例如 `web/``api-server``spacetime_module.wasm``.env*``start.sh``stop.sh``web-server.mjs``README.md`
3. 将指定版本目录中的同名发布产物复制到部署目录;文件产物使用普通复制,`web/` 等目录产物必须递归复制
4. 如果 `CLEAR_DATABASE=true`,部署脚本会以 `./start.sh --clear-database` 启动新版本;这样发布阶段的 `spacetime publish` 会追加 `-c=on-conflict`
5. 执行新版本 `start.sh`
2. 覆盖前如果旧部署目录存在 `migration-bootstrap-secret.txt`,先复制到 `deploy-state/migration-bootstrap-secret.previous.txt`,供新版本 `start.sh` 在 schema 冲突自动迁移时授权导出旧库。该文件属于 Jenkins 部署状态,不放入 `run/`,避免 `sudo` 启停脚本生成的 root 私有运行目录阻断后续部署写入;如果后续部署失败,部署脚本必须把该快照复制回部署目录根下的 `migration-bootstrap-secret.txt`,避免当前仍在运行的数据库丢失对应迁移引导密钥
3. 只删除发布产物白名单中的旧文件,例如 `web/``api-server``spacetime_module.wasm``migration-bootstrap-secret.txt``scripts/``.env*``start.sh``stop.sh``web-server.mjs``README.md`
4. 将指定版本目录中的同名发布产物复制到部署目录;文件产物使用普通复制,`web/``scripts/` 等目录产物必须递归复制
5. `WEB_PORT``MIGRATE_ON_CONFLICT``MIGRATION_DIRECTORY` 写入部署目录 `.env.local`,确保通过 sudo 执行 `start.sh` 时仍能读取 Jenkins 参数;启动前读取 `.env``.env.local` 中最终的 `GENARRATIVE_SPACETIME_DATABASE`,打印并校验其符合 SpacetimeDB 数据库名规则。Jenkins 参数 `MIGRATION_EXPORT_TOKEN` / `MIGRATION_IMPORT_TOKEN` 会分别写入 `GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN` / `GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN`;如果参数为空,部署目录已有同名变量时会尽量保留
6. 如果 `CLEAR_DATABASE=true`,部署脚本会以 `./start.sh --clear-database` 启动新版本;这样发布阶段的 `spacetime publish` 会追加 `-c=on-conflict`,代表人工确认清库,不进入自动导出和回灌。
7. 执行新版本 `start.sh`;普通发布遇到 schema 冲突时,默认由发布包内迁移脚本自动导出旧库、清库发布新 wasm、导入回灌。
如果 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`第 1 步和第 4 步会改为 `sudo -n` 调用;这要求 Jenkins 运行用户提前配置免密 sudo否则部署会直接失败不会进入交互式密码提示。
如果 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`旧版本 `stop.sh` 和新版本 `start.sh` 会改为 `sudo -n` 调用;这要求 Jenkins 运行用户提前配置免密 sudo否则部署会直接失败不会进入交互式密码提示。
这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `.spacetimedb/``logs/``run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env``.env.local` 会先以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF并把 Jenkins 部署参数 `WEB_PORT` 写入 `.env.local``GENARRATIVE_WEB_PORT`,避免 `start.sh` 在 Bash 下把首行变量名误解析成命令,也避免端口配置只停留在上游构建阶段。`start.sh` 会先执行 Ubuntu 专用 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin/<version>/spacetimedb-cli``$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到部署目录 `.spacetimedb/bin/current/spacetimedb-cli`,后续启动、探活和 root-dir 占用判定都使用部署目录内 `.spacetimedb/`,且不再额外设置 `--data-dir`,避免 Jenkins 机器全局 `spacetime login` 变化影响本地库更新;如遇 `403 Forbidden`,按 `SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md` 排查数据库所有者与 CLI 身份。
这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `.spacetimedb/``logs/``run/``deploy-state/``database-migrations/` 这类运行态目录,不会因为部署被整体删除。`run/` 只承载 pid 等启停运行状态;`deploy-state/` 承载 Jenkins 覆盖部署前保存的旧迁移引导密钥,必须由 Jenkins 用户保持可写,并在部署失败时作为恢复源写回根目录 `migration-bootstrap-secret.txt`。发布白名单内的 `.env``.env.local` 会先以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF并把 Jenkins 部署参数 `WEB_PORT` 写入 `.env.local``GENARRATIVE_WEB_PORT`,把 `MIGRATE_ON_CONFLICT` 写入 `GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT`,把 `MIGRATION_DIRECTORY` 写入 `GENARRATIVE_SPACETIME_MIGRATION_DIR`,并在启动前输出最终 `GENARRATIVE_SPACETIME_DATABASE`,避免 `start.sh` 在 Bash 下把首行变量名误解析成命令,也避免端口、数据库名和迁移配置只停留在上游构建阶段。`start.sh` 会先执行 Ubuntu 专用 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin/<version>/spacetimedb-cli``$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到部署目录 `.spacetimedb/bin/current/spacetimedb-cli`,后续启动、探活和 root-dir 占用判定都使用部署目录内 `.spacetimedb/`,且不再额外设置 `--data-dir`,避免 Jenkins 机器全局 `spacetime login` 变化影响本地库更新;如遇 `403 Forbidden`,按 `SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md` 排查数据库所有者与 CLI 身份。
### 4.3 构建并部署
@@ -113,18 +122,22 @@ jenkins/Jenkinsfile.build-and-deploy
核心流程:
1. `checkout scm`执行 `git reset --hard HEAD``git clean -fd` 清理工作区
2. 复用与 `构建` 相同的构建命令生成 `build/<BUILD_VERSION>/`
3. 归档 `build/<BUILD_VERSION>/**`
4. 记录当前 `NODE_NAME`、源码根目录、版本号
5. 构建时额外透传 `--web-port <WEB_PORT>`,默认生成监听 `25001` 的发布包
6. 触发 `部署` 流水线,并传递:
1. `checkout scm`,如果 `COMMIT_HASH` 非空,则先拉取远端分支和 tag解析该 hash 指向的 commit并 detached checkout 到该提交
2. 执行 `git reset --hard HEAD``git clean -fd` 清理工作区
3. 复用与 `构建` 相同的构建命令生成 `build/<BUILD_VERSION>/`
4. 归档 `build/<BUILD_VERSION>/**`
5. 记录当前 `NODE_NAME`、源码根目录、版本号与实际构建 commit
6. 构建时额外透传 `--web-port <WEB_PORT>`,默认生成监听 `25001` 的发布包。
7. 构建日志会输出 `SpacetimeDB 发布数据库: <DATABASE>``构建 commit: <COMMIT>`,发布包启动日志会输出最终 `database/server/root-dir`
8. 触发 `部署` 流水线,并传递:
- `BUILD_VERSION`
- `SOURCE_WORKSPACE_ROOT`
- `SOURCE_NODE_NAME`
- `DEPLOY_DIRECTORY`
- `WEB_PORT`
- `CLEAR_DATABASE`
- `MIGRATE_ON_CONFLICT`
- `MIGRATION_DIRECTORY`
- `EXPECTED_UPSTREAM_JOB`
## 5. Jenkins 参数建议
@@ -137,8 +150,13 @@ jenkins/Jenkinsfile.build-and-deploy
4. `RUN_NPM_CI`:是否在构建前执行 `npm ci`
5. `WEB_PORT`:静态网站监听端口;`构建并部署` 默认值为 `25001`,并通过下游 `部署` 同名参数作为最终启动端口。
6. `CLEAR_DATABASE`:部署阶段是否清空 SpacetimeDB 数据后再发布 wasm默认 `false`
7. `MIGRATE_ON_CONFLICT`:普通部署遇到 SpacetimeDB schema 冲突时是否自动导出、清库发布、导入回灌;默认 `true`
8. `MIGRATION_DIRECTORY`:自动迁移 JSON 输出目录;留空时使用部署目录内 `database-migrations/<database>`
9. `MIGRATION_EXPORT_TOKEN`:可选,旧库已授权迁移操作员 token只在 schema 冲突导出旧库时使用。
10. `MIGRATION_IMPORT_TOKEN`:可选,新库已授权迁移操作员 token只在清库发布新 wasm 后导入回灌时使用。
如果当前 Jenkins 没有额外配置独立 Agent而是直接在控制器自身执行任务`AGENT_LABEL` 应填写 `built-in`
如果 `node``cargo``spacetime` 安装在 Jenkins 用户目录下,`构建并部署` 已默认把 `/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin``/var/lib/jenkins/.cargo/bin``/var/lib/jenkins/.local/bin``/var/lib/jenkins/bin` 写入流水线 `PATH` 前缀;仍应确保这些目录和其中二进制文件对 Jenkins 运行用户可读可执行。
如果 Jenkins 进程以默认 `jenkins` 用户运行,部署目录建议直接放在 `/var/lib/jenkins/deploy/Genarrative` 这类 Jenkins 自有目录下,避免再依赖 `/home/ubuntu/*` 的额外写权限。
如果目标 Ubuntu 的 Jenkins `sh` 默认实际落到 `/bin/sh -> dash`,而流水线脚本又使用了 `set -euo pipefail`,则必须显式通过 `bash -lc` 执行命令,不能直接依赖 Jenkins 默认 `sh` 解释器。
@@ -148,9 +166,13 @@ jenkins/Jenkinsfile.build-and-deploy
2. `SOURCE_NODE_NAME`
3. `DEPLOY_DIRECTORY`
4. `CLEAR_DATABASE`
5. `RUN_DEPLOY_HOOKS_WITH_SUDO`
6. `EXPECTED_UPSTREAM_JOB`
7. `WEB_PORT`
5. `MIGRATE_ON_CONFLICT`
6. `MIGRATION_DIRECTORY`
7. `RUN_DEPLOY_HOOKS_WITH_SUDO`
8. `EXPECTED_UPSTREAM_JOB`
9. `WEB_PORT`
10. `MIGRATION_EXPORT_TOKEN`
11. `MIGRATION_IMPORT_TOKEN`
其中仅 `构建并部署` 流水线还需要:
@@ -158,6 +180,12 @@ jenkins/Jenkinsfile.build-and-deploy
2. `RUN_DEPLOY_HOOKS_WITH_SUDO`
3. `WEB_PORT`
4. `CLEAR_DATABASE`
5. `MIGRATE_ON_CONFLICT`
6. `MIGRATION_DIRECTORY`
7. `MIGRATION_EXPORT_TOKEN`
8. `MIGRATION_IMPORT_TOKEN`
9. `DATABASE`:发布包默认数据库名,默认 `genarrative-pipeline-local-test`,必须匹配 `^[a-z0-9]+(-[a-z0-9]+)*$`
10. `COMMIT_HASH`:可选指定构建提交;如果目标 commit 不在 Jenkins 当前浅克隆历史中,流水线会尝试 unshallow仍找不到时构建失败。
如果你选择启用 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,推荐提前在服务器上增加一份最小 sudoers 配置,例如:

View File

@@ -0,0 +1,118 @@
# Jenkins SpacetimeDB 数据库导入导出流水线方案
日期:`2026-04-29`
## 1. 目标
为 Jenkins 增加两条人工触发的数据库迁移流水线:
1. `Genarrative-Database-Export`:调用仓库内 `scripts/spacetime-export-migration-json.mjs`,通过 SpacetimeDB 迁移导出 procedure 生成迁移 JSON并归档为 Jenkins 产物。
2. `Genarrative-Database-Import`:调用仓库内 `scripts/spacetime-import-migration-json.mjs`,通过 SpacetimeDB 迁移导入 procedure 导入迁移 JSON默认只执行 `dry-run`
本方案只编排已有迁移脚本,不在 Jenkinsfile 中重新实现表结构枚举、JSON 解析或 SQL 拼接逻辑。
## 2. 执行依据
1. SpacetimeDB CLI 调用按仓库技能 `spacetimedb-cli` 执行,数据库调用通过 `spacetime call` 或 HTTP procedure API 完成。
2. SpacetimeDB 读写语义按 `spacetimedb-concepts` 执行:导入导出能力由模块内 procedure/reducer 负责校验和事务处理Jenkins 不直接改表。
3. 迁移脚本复用当前仓库的参数解析与错误处理:
- `scripts/spacetime-export-migration-json.mjs`
- `scripts/spacetime-import-migration-json.mjs`
- `scripts/spacetime-migration-common.mjs`
## 3. Jenkins 作业
### 3.1 数据库导出
脚本路径:
```text
jenkins/Jenkinsfile.database-export
```
推荐作业名:
```text
Genarrative-Database-Export
```
关键参数:
1. `DATABASE`:目标 SpacetimeDB 数据库名;留空时读取仓库环境变量。
2. `SERVER`SpacetimeDB server 别名,默认 `maincloud`
3. `SERVER_URL`:显式服务地址;填写后优先于 `SERVER`
4. `DEPLOY_DIRECTORY`:固定部署目录,默认 `/var/lib/jenkins/deploy/Genarrative`
5. `ROOT_DIR`:可选,透传给 `spacetime --root-dir`;为空时使用 `<DEPLOY_DIRECTORY>/.spacetimedb`
6. `INCLUDE_TABLES`:可选,逗号分隔的表名白名单。
7. `OUTPUT_DIRECTORY`:导出文件目录,默认 `database-exports`
8. `EXPORT_NAME`:导出文件名;留空时使用 `spacetime-migration-<BUILD_NUMBER>.json`
导出成功后Jenkins 归档:
```text
<OUTPUT_DIRECTORY>/<EXPORT_NAME>
```
### 3.2 数据库导入
脚本路径:
```text
jenkins/Jenkinsfile.database-import
```
推荐作业名:
```text
Genarrative-Database-Import
```
关键参数:
1. `INPUT_FILE`:必填,迁移 JSON 文件路径。
2. `DATABASE``SERVER``SERVER_URL``DEPLOY_DIRECTORY``ROOT_DIR`:与导出流水线一致。
3. `INCLUDE_TABLES`:可选,只导入指定表。
4. `CHUNK_SIZE`:迁移 JSON 分片大小,默认 `524288` bytes。导入脚本会在文件超过该大小或直接导入触发 HTTP 413 时自动分片上传。
5. `DRY_RUN`:默认 `true`,只校验不写入。
6. `INCREMENTAL`:默认 `true`,跳过已存在或冲突的行。
7. `REPLACE_EXISTING`:默认 `false`,只覆盖本次迁移文件中涉及的表;不可与 `INCREMENTAL` 同时启用。
8. `BOOTSTRAP_SECRET`:可选,用于授权临时 Web API identity。
9. `TOKEN`可选SpacetimeDB 客户端连接 token留空时脚本会自动创建临时 identity 并在结束后撤销。
10. `NOTE`:迁移授权备注。
## 4. 安全边界
1. 导入流水线默认 `DRY_RUN=true`,需要人工明确关闭才会写入数据。
2. `INCREMENTAL``REPLACE_EXISTING` 互斥Jenkinsfile 会在执行前阻止同时启用。
3. Jenkinsfile 不打印 token生产环境应通过 Jenkins 凭据或目标机器环境变量传入敏感值。
4. 如果不传 `TOKEN`,导入脚本会创建临时 Web API identity并调用迁移授权/撤销 procedure 收敛权限窗口。
5. 导入导出流水线在调用仓库内迁移脚本前都会执行 `git reset --hard HEAD`,确保固定源码目录中的本地改动不会影响本次迁移操作。
6. 如果日志出现 `SpacetimeDB HTTP 413: Failed to buffer the request body: length limit exceeded`,优先把 `CHUNK_SIZE` 调低到 `262144` 或更小后重跑。该参数只降低单次 HTTP body不改变导入表范围。
## 5. 本地部署测试参数
`Genarrative-Build-And-Deploy` 增加以下本地发布包参数,便于在 Jenkins 中测试本地 SpacetimeDB不依赖 Maincloud
1. `DATABASE`:发布包默认数据库名,默认 `genarrative-pipeline-local-test`。SpacetimeDB CLI 当前要求数据库名匹配 `^[a-z0-9]+(-[a-z0-9]+)*$`,只能使用小写字母、数字,并用单个短横线分隔;不要使用大写字母、点号、下划线、首尾短横线或连续短横线。
2. `API_PORT`:发布包内 api-server 端口,默认 `8082`
3. `WEB_PORT`:发布包内静态网站端口,默认 `25001`
4. `SPACETIME_PORT`:发布包内本地 SpacetimeDB 端口,默认 `3101`
5. `DEPLOY_DIRECTORY`:固定部署目录,继续透传给 `Genarrative-Deploy`
数据库导入导出流水线在本地测试时应显式填写:
```text
DATABASE=genarrative-pipeline-local-test
SERVER_URL=http://127.0.0.1:3101
DEPLOY_DIRECTORY=/var/lib/jenkins/deploy/Genarrative
```
这样脚本会自动使用 `/var/lib/jenkins/deploy/Genarrative/.spacetimedb` 作为 `spacetime --root-dir`,避免回退到 Jenkins 用户全局 CLI 登录态,也避免误连 Maincloud。
## 6. 文件清单
```text
jenkins/Jenkinsfile.database-export
jenkins/Jenkinsfile.database-import
docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md
```

View File

@@ -0,0 +1,79 @@
# RPG 剧情与模板创作模型路由调整2026-04-30
## 1. 背景
当前 `server-rs` 的文本模型主链统一通过 `platform-llm` 走 Ark OpenAI 兼容 `/chat/completions`。本轮模型切换有两个不同边界:
1. RPG 运行时剧情推理继续使用 Ark `/chat/completions`,但模型固定为 `doubao-seed-character-251128`
2. 模板创作流程的大模型推理统一使用 Ark `/responses`,模型固定为 `deepseek-v3-2-251201`,并按 Responses API 的 `tools: [{ type: "web_search", max_keyword: 3 }]` 方式启用联网搜索。
因此本次不能只替换 `GENARRATIVE_LLM_MODEL` 默认值。默认值仍可能被通用代理或其他兼容调用使用RPG 剧情与模板创作需要在业务请求上显式覆盖模型和协议,避免两条主链互相污染。
## 2. 落地范围
### 2.1 RPG 剧情推理
以下运行时 RPG 推理请求必须显式使用:
- model: `doubao-seed-character-251128`
- protocol: `/chat/completions`
覆盖入口:
1. `runtime_story/compat/ai.rs`
- 首段剧情与继续剧情。
- NPC 对话剧情文本。
- 预留的动作结果叙事生成。
2. `runtime_chat.rs`
- NPC 单轮聊天回复。
- NPC 单轮后续建议。
3. `runtime_chat_plain.rs`
- 角色私聊回复、建议、摘要。
- NPC 对话、招募对话等纯文本流。
### 2.2 模板创作流程
以下创作链路必须显式使用:
- model: `deepseek-v3-2-251201`
- protocol: `/responses`
- web_search: 开启时映射为 `tools: [{ "type": "web_search", "max_keyword": 3 }]`
覆盖入口:
1. `creation_agent_llm_turn.rs`
- RPG/自定义世界 Agent 单轮 JSON turn。
- 大鱼吃小鱼 Agent 单轮 JSON turn。
- 拼图 Agent 单轮 JSON turn。
- 动态状态判断等非流式 JSON turn。
2. `custom_world_foundation_draft.rs`
- 世界框架、角色、场景、角色详情等分阶段底稿生成。
- JSON 修复阶段。
3. `custom_world_agent_entities.rs`
- 结果页新增角色/地点生成。
4. `custom_world_ai.rs`
- 结果页兜底补齐实体生成。
5. `big_fish_draft_compiler.rs`
- 大鱼吃小鱼草稿结构化编译与 JSON 修复。
图片、视频、OSS、SpacetimeDB reducer 不属于本次模型切换范围。
## 3. 平台层改造
`platform-llm` 保留原 `/chat/completions` 能力,并新增 Responses 协议:
1. `LlmTextProtocol::ChatCompletions`
2. `LlmTextProtocol::Responses`
3. `LlmTextRequest::with_responses_api()`
4. `LlmConfig::responses_url()`
Responses 非流式解析优先读取 `output_text`,再兼容 `output[].content[].text`。Responses 流式解析只把 `response.output_text.delta``delta` 推给上层,避免把 reasoning summary、工具事件或完成事件误拼进玩家可见文本。
## 4. 验收标准
1. RPG 运行时 LLM 请求在代码层显式带 `doubao-seed-character-251128`
2. 创作模板 LLM 请求在代码层显式带 `deepseek-v3-2-251201` 与 Responses 协议。
3. `platform-llm` 单测覆盖 Responses 非流式、Responses SSE、Responses web_search tools 请求体。
4. `cargo test -p platform-llm --manifest-path server-rs/Cargo.toml` 通过。
5. `cargo test -p api-server creation_agent_llm_turn --manifest-path server-rs/Cargo.toml` 通过。
6. 修改后按项目约束使用 `npm run api-server:maincloud` 重新启动后端,并执行相应自动测试。

View File

@@ -406,6 +406,12 @@ Node 侧入口位于:
5. `profile_dashboard_state.total_play_time_ms` 通过同一用户同一世界的 `runtimeStats.playTimeMs - last_observed_play_time_ms` 增量累积,后端使用 `saturating_sub` 防止旧快照回退导致负增量。
6. 作品卡上的公开热度计数如果需要覆盖 RPG 作品,应另立公开作品统计方案;不能把个人 `profile_played_world` 误当成全站作品 `playCount`
## 10.2 2026-05-01 新用户注册赠送修正
新注册用户默认获得 `10` 个光点,注册链路通过 SpacetimeDB procedure 写入 `profile_dashboard_state.wallet_balance``profile_wallet_ledger`。流水来源为 `new_user_registration_reward`,流水 ID 固定为 `new-user-registration:{user_id}`,重复调用不重复发放。
注册赠送、邀请码奖励、充值、兑换码、资产扣费等都属于真实平台钱包流水。用户只要已经存在非 `snapshot_sync` 钱包流水,后续 `runtime_snapshot.game_state.playerCurrency` 不再覆盖 `wallet_balance`,只继续刷新游玩时长和玩过世界,避免首次保存旧运行态货币字段时把注册赠送覆盖成 `0`
## 11. 测试策略
### 11.1 必跑

View File

@@ -0,0 +1,842 @@
# 抓大鹅 Match3D 创作与运行态最小落地技术方案 2026-04-30
## 1. 文档目的
本文件承接 PRD《AI 原生抓大鹅 Match3D 玩法创作工具与玩法系统 PRD》冻结首版 demo 的最小可开发方案。
本轮目标不是先做一个纯前端临时小游戏,而是在当前平台内新增独立 `match3d` 玩法域,跑通下面这条最小主链:
1. 平台创作入口选择“抓大鹅”。
2. Agent 对话确认题材、需要消除次数和难度。
3. 编译 Match3D 草稿。
4. 进入结果页编辑游戏名称、标签和封面图。
5. 发布前试玩,可随时停止并返回修改。
6. 发布作品。
7. 玩家进入单局运行态。
8. 前端即时呈现点击、飞入、入槽、三消、腾格、胜负过渡。
9. 后端权威确认点击、入槽、消除、失败、胜利和成绩。
本文是后续并行开发的工程合同。若实现过程中发现字段、路由、表结构或前后端职责需要变化,必须先更新本文,再进入对应编码分支。
---
## 2. 本轮明确不做
1. 不做多关卡链。
2. 不做排行榜展示。
3. 不做道具逻辑,只预留字段和扩展点。
4. 不做真实 3D 模型和真实 3D 物理遮挡。
5. 不做洗牌、重置、旋转、放大等局内操作。
6. 不做必须试玩通关才能发布。
7. 不做前端本地最终规则真相。
8. 不接入 `server-node` 或 PostgreSQL。
9. 不把 Match3D 挂到 RPG、拼图或大鱼吃小鱼旧命名下。
---
## 3. 分层边界
## 3.1 前端
前端继续使用当前 `React + TypeScript + Vite` 平台壳层,负责所有即时呈现的局内反馈:
1. 创作入口、Agent 工作区、结果页、试玩和运行态 UI。
2. 参考图片上传入口。
3. 运行态圆形空间、2D 物品、倒计时和 `7` 格备选栏展示。
4. 基于后端快照做 2D 命中检测、悬停、按压、选中反馈。
5. 在等待后端确认期间,先行播放飞入、入槽、三消、腾格、胜利和失败过渡。
6. 收到后端确认后,以权威快照校正本地表现。
7. 后端拒绝或版本冲突时,回滚本次即时反馈。
前端禁止:
1. 把本地即时反馈作为最终规则真相。
2. 本地生成可提交成绩。
3. 本地伪造胜利、失败、消除计数或运行态持久化结果。
4. 在 UI 中默认展示长篇玩法规则说明。
## 3.2 api-server
`server-rs/crates/api-server` 负责 Match3D 对外 HTTP facade
1. 鉴权、请求上下文、错误 envelope。
2. 创作 Agent 的 LLM turn 编排。
3. 参考图片上传复用现有资产/OSS 能力。
4. 调用 `spacetime-client` 读写 Match3D 会话、作品和运行态。
5. 对前端返回稳定 HTTP DTO不泄露 SpacetimeDB 内部表结构。
## 3.3 SpacetimeDB
`server-rs/crates/spacetime-module` 负责 Match3D 真相态:
1. 存储 Agent session / message。
2. 存储作品 profile。
3. 存储运行态 run snapshot。
4. 通过 procedure 同步返回会话、作品和运行态快照。
5. 在 reducer/procedure 内保持确定性,不做网络、文件系统或外部模型调用。
## 3.4 纯领域 crate
新增:
```text
server-rs/crates/module-match3d
```
职责:
1. 创作配置校验。
2. 物品类型规划。
3. 初始布局生成输入/输出模型。
4. 2D 遮挡与可点击快照计算。
5. 点击确认。
6. 入槽与三消确认。
7. 胜负确认。
8. 成绩基础数据计算。
`module-match3d` 不直接依赖 Axum、不访问 OSS、不调用 LLM、不读写 SpacetimeDB 表。
---
## 4. 共享契约
## 4.1 TypeScript shared contracts
新增:
```text
packages/shared/src/contracts/match3dAgent.ts
packages/shared/src/contracts/match3dWorks.ts
packages/shared/src/contracts/match3dRuntime.ts
```
### `match3dAgent.ts`
承载:
1. `Match3DAgentSession`
2. `Match3DAgentMessage`
3. `Match3DCreatorConfig`
4. `Match3DCompileDraftRequest`
5. `Match3DCompileDraftResult`
### `match3dWorks.ts`
承载:
1. `Match3DWorkProfile`
2. `Match3DWorkSummary`
3. `Match3DWorkUpdateRequest`
4. `Match3DPublishRequest`
5. `Match3DPublishResult`
### `match3dRuntime.ts`
承载:
1. `Match3DRunSnapshot`
2. `Match3DItemSnapshot`
3. `Match3DTraySlot`
4. `Match3DStartRunRequest`
5. `Match3DClickItemRequest`
6. `Match3DClickItemResult`
7. `Match3DStopRunRequest`
8. `Match3DRestartRunRequest`
## 4.2 Rust shared contracts
新增:
```text
server-rs/crates/shared-contracts/src/match3d_agent.rs
server-rs/crates/shared-contracts/src/match3d_works.rs
server-rs/crates/shared-contracts/src/match3d_runtime.rs
```
并在 `server-rs/crates/shared-contracts/src/lib.rs` 导出。
Rust DTO 只承载 HTTP contract 和跨 crate 稳定模型,不直接暴露 `module-match3d` 内部结构。
## 4.3 命名约束
1. 对外展示:抓大鹅。
2. 工程域:`match3d`
3. TypeScript 类型前缀:`Match3D`
4. Rust 类型前缀:`Match3D`
5. HTTP path`/api/creation/match3d/*``/api/runtime/match3d/*`
6. SpacetimeDB 表与 procedure 前缀:`match3d_`
---
## 5. SpacetimeDB 表
首版保持最小闭环,复杂结构统一使用结构化字段 + `snapshot_json` / `draft_json`,避免过早拆出多张高耦合子表。
新增表属于安全 schema 演进;后续如果改字段,必须遵守 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`,不能直接删除、重排或改名已有列。表结构变更后必须同步对齐 `migration.rs`
## 5.1 `match3d_agent_session`
作用:保存 Match3D 创作 Agent 会话、配置草稿和发布指针。
字段:
1. `session_id: String`,主键。
2. `owner_user_id: String`,索引。
3. `seed_text: String`,用户初始输入或自动配置摘要。
4. `current_turn: u32`
5. `progress_percent: u32`
6. `stage: String`,建议值:`Collecting``ReadyToCompile``DraftCompiled``Published`
7. `config_json: String`,序列化 `Match3DCreatorConfig`
8. `draft_json: String`,序列化草稿结果。
9. `last_assistant_reply: String`
10. `published_profile_id: String`,未发布为空字符串。
11. `created_at: Timestamp`
12. `updated_at: Timestamp`
## 5.2 `match3d_agent_message`
作用:保存 Match3D 创作 Agent 消息流水。
字段:
1. `message_id: String`,主键。
2. `session_id: String`,索引。
3. `role: String`,建议值:`user``assistant``system`
4. `kind: String`,建议值:`text``action``error`
5. `text: String`
6. `created_at: Timestamp`
## 5.3 `match3d_work_profile`
作用:保存 Match3D 作品主表和发布状态。
字段:
1. `profile_id: String`,主键。
2. `owner_user_id: String`,索引。
3. `source_session_id: String`
4. `author_display_name: String`
5. `game_name: String`
6. `theme_text: String`
7. `summary_text: String`
8. `tags_json: String`
9. `cover_image_src: String`
10. `cover_asset_id: String`
11. `clear_count: u32`
12. `difficulty: u32`
13. `config_json: String`
14. `publication_status: String`,建议值:`Draft``Published`
15. `play_count: u32`
16. `updated_at: Timestamp`
17. `published_at: Option<Timestamp>`,未发布为 `None`
## 5.4 `match3d_runtime_run`
作用:保存 Match3D 单局运行态快照和成绩基础数据。
字段:
1. `run_id: String`,主键。
2. `owner_user_id: String`,索引。
3. `profile_id: String`,索引。
4. `status: String`,建议值:`Running``Won``Failed``Stopped`
5. `snapshot_version: u32`
6. `started_at_ms: i64`
7. `duration_limit_ms: i64`,首版固定 `600000`
8. `finished_at_ms: i64`,未结束为 `0`
9. `elapsed_ms: i64`
10. `clear_count: u32`
11. `total_item_count: u32`
12. `cleared_item_count: u32`
13. `failure_reason: String`,建议值为空、`TimeUp``TrayFull`
14. `snapshot_json: String`,序列化 `Match3DRunSnapshot`
15. `created_at: Timestamp`
16. `updated_at: Timestamp`
## 5.5 `match3d_play_record`
首版可选,若本轮不做排行榜,可先不建表,只在 `match3d_runtime_run` 保留成绩字段。
若实现成绩历史,字段建议:
1. `record_id: String`,主键。
2. `profile_id: String`,索引。
3. `owner_user_id: String`,索引。
4. `run_id: String`
5. `status: String`
6. `elapsed_ms: i64`
7. `cleared_item_count: u32`
8. `total_item_count: u32`
9. `created_at: i64`
---
## 6. SpacetimeDB procedure
本轮全部使用 procedure 同步返回快照,避免 `api-server` 在写入后再读 private table。
## 6.1 创作链
1. `create_match3d_agent_session(input)`
创建会话,写入初始配置或空配置,返回 session snapshot。
2. `get_match3d_agent_session(input)`
获取会话、消息和当前 draft。
3. `submit_match3d_agent_message(input)`
只写 user message不调用 LLM不生成 assistant 回复。
4. `finalize_match3d_agent_message_turn(input)`
`api-server` LLM turn 完成后写入 assistant message、配置状态、进度和 `last_assistant_reply`
5. `compile_match3d_draft(input)`
校验题材、需要消除次数、难度,生成草稿和作品 draft profile。
## 6.2 作品链
1. `update_match3d_work(input)`
更新游戏名称、标签、封面、题材、需要消除次数和难度。
2. `publish_match3d_work(input)`
校验基础信息完整后发布作品,不要求试玩通关。
3. `list_match3d_works(input)`
查询当前用户作品。
4. `get_match3d_work_detail(input)`
查询作品详情,支持结果页恢复和作品详情页。
5. `delete_match3d_work(input)`
可后置;若接入创作中心删除,需要与其他玩法卡片删除语义一致。
## 6.3 运行态链
1. `start_match3d_run(input)`
基于作品配置生成单局快照,返回 `Match3DRunSnapshot`
2. `get_match3d_run(input)`
返回当前权威运行态快照。
3. `click_match3d_item(input)`
根据 `run_id / item_instance_id / client_snapshot_version` 权威确认点击、入槽、三消、失败或胜利,返回新快照和确认结果。
4. `stop_match3d_run(input)`
把运行态标记为 `Stopped`,供试玩中止和返回结果页使用。
5. `restart_match3d_run(input)`
复用同一作品配置创建新 run返回新快照。
6. `finish_match3d_time_up(input)`
可选。若倒计时由前端触发,前端在倒计时归零时调用该 procedure后端确认 `TimeUp`。也可以由 `click_match3d_item``get_match3d_run` 懒确认超时。
## 6.4 procedure 输入输出约束
1. 所有 mutation 输入必须带 `owner_user_id` 或由 `api-server` 注入用户上下文SpacetimeDB 内部仍需以可信身份或 owner 字段校验归属。
2. 运行态 mutation 必须携带 `client_snapshot_version`
3. 若版本不匹配,返回 `VersionConflict`,并携带最新快照。
4. procedure 返回字符串化 JSON 时,`spacetime-client` 必须负责反序列化和错误归一化。
---
## 7. 运行态确认协议
Match3D 首版采用“前端即时反馈 + 后端权威确认”。
## 7.1 点击流程
```text
玩家点击物品
-> 前端基于最新快照做 2D 命中检测
-> 前端立即播放按压/选中/飞入表现
-> 前端调用 click_match3d_item
-> 后端确认点击是否合法
-> 后端返回新快照与确认结果
-> 前端按确认结果固化或回滚表现
```
## 7.2 点击请求
```ts
interface Match3DClickItemRequest {
runId: string;
itemInstanceId: string;
clientSnapshotVersion: number;
clientEventId: string;
clickedAtMs: number;
}
```
字段说明:
1. `clientSnapshotVersion` 用于发现前端基于旧快照操作。
2. `clientEventId` 用于前端去重和日志定位。
3. `clickedAtMs` 只用于观测,不作为成绩可信时间源。
## 7.3 点击结果
```ts
type Match3DClickConfirmStatus =
| 'Accepted'
| 'RejectedNotClickable'
| 'RejectedAlreadyMoved'
| 'RejectedTrayFull'
| 'VersionConflict'
| 'RunFinished';
interface Match3DClickItemResult {
status: Match3DClickConfirmStatus;
run: Match3DRunSnapshot;
acceptedItemInstanceId?: string;
clearedItemInstanceIds: string[];
failureReason?: 'TimeUp' | 'TrayFull';
}
```
## 7.4 前端回滚规则
1. `Accepted`:固化飞入、入槽、消除或胜负表现。
2. `RejectedNotClickable`:被点物品回到原位,备选栏恢复。
3. `RejectedAlreadyMoved`:直接应用后端最新快照。
4. `RejectedTrayFull`:应用后端失败快照。
5. `VersionConflict`:取消当前局部动画,应用最新快照,允许用户继续操作。
6. `RunFinished`:应用后端胜负快照,进入结算。
## 7.5 快照版本
1. 每次后端接受会改变运行态的操作,`snapshot_version` 必须递增。
2. 前端所有即时反馈都基于某个明确版本。
3. 前端同时只能有一个未确认的点击操作;首版不做多点击并发队列。
4. 如果动画期间用户再次点击,前端应忽略或排队到当前确认完成后再处理;首版建议忽略。
---
## 8. 运行态快照
## 8.1 `Match3DRunSnapshot`
```ts
interface Match3DRunSnapshot {
runId: string;
profileId: string;
status: 'Running' | 'Won' | 'Failed' | 'Stopped';
snapshotVersion: number;
startedAtMs: number;
durationLimitMs: number;
serverNowMs: number;
remainingMs: number;
clearCount: number;
totalItemCount: number;
clearedItemCount: number;
traySlots: Match3DTraySlot[];
items: Match3DItemSnapshot[];
failureReason?: 'TimeUp' | 'TrayFull';
}
```
说明:
1. `serverNowMs` 用于前端校准倒计时。
2. `remainingMs` 由后端按 `durationLimitMs` 和服务端时间计算。
3. 前端可以本地递减倒计时,但归零失败必须调用后端确认或等待下一次后端确认。
## 8.2 `Match3DItemSnapshot`
```ts
interface Match3DItemSnapshot {
itemInstanceId: string;
itemTypeId: string;
visualKey: string;
x: number;
y: number;
radius: number;
layer: number;
state: 'InBoard' | 'Flying' | 'InTray' | 'Cleared';
clickable: boolean;
}
```
说明:
1. `Flying` 可以作为前端表现态使用,不要求后端逐帧落库。
2. 后端主要确认 `InBoard -> InTray -> Cleared` 的权威状态变化。
3. `clickable` 是后端计算给前端的可点击快照,前端命中检测必须尊重它。
4. `x / y / radius` 统一使用 `0~1` 归一化舞台坐标。圆心为 `(0.5, 0.5)`,圆形可用半径为 `0.5`
5. 后端生成物品时必须保证 `distance((x, y), (0.5, 0.5)) + radius <= 0.5 - safeMargin`。首版 `safeMargin` 用于覆盖圆形边框和阴影,避免物品被边界压住或裁切。
6. 前端渲染收到旧快照或异常坐标时,可以只做显示层兜底收束,但不得把兜底后的表现坐标写回为规则真相。
## 8.3 `Match3DTraySlot`
```ts
interface Match3DTraySlot {
slotIndex: number;
itemInstanceId?: string;
itemTypeId?: string;
visualKey?: string;
}
```
## 8.4 2D 遮挡口径
首版不做真实物理遮挡。
建议后端按以下输入计算 `clickable`
1. 物品圆形或近似圆形碰撞范围。
2. `layer` 越大越靠上。
3. 被更高层物品覆盖到低于可点击阈值时,标记为不可点击。
4. 阈值首版作为领域常量,后续体验后再参数化。
前端基于 `clickable` 和自身命中检测呈现即时反馈;后端仍在点击确认时再次校验。
---
## 9. 领域规则冻结
## 9.1 创作配置
```ts
interface Match3DCreatorConfig {
themeText: string;
referenceImageSrc?: string;
clearCount: number;
difficulty: number;
}
```
规则:
1. `themeText` 必填。
2. `clearCount` 必须为正整数。
3. `difficulty` 范围 `1~10`
4. `referenceImageSrc` 首版只支持图片,不支持视频。
## 9.2 物品数量
```text
totalItemCount = clearCount * 3
```
每种 `itemTypeId` 的数量必须是 `3` 的倍数。
## 9.3 demo 视觉素材
首版使用内置视觉键和前端内置几何图形资产,不接真实图片生成。
1. 水果题材必须使用 `watermelon-green / apple-red / banana-yellow / grape-purple / melon-green / berry-blue / peach-pink / plum-indigo / lime-lime / orange-orange` 这组内置水果视觉键;前端首版将其映射为纯色几何体,不渲染水果写实图,也不能显示为带文字或透明气泡的小球。
2. 非水果题材暂使用 `red_circle / yellow_triangle / purple_diamond / green_square / blue_star / orange_hexagon / cyan_capsule / pink_heart / lime_leaf / white_moon` 这组兜底颜色形状视觉键。
3. `visualKey` 不允许在前端统一兜底为同一个素材;未知 key 至少要有稳定的颜色差异,避免多个不同 `itemTypeId` 被玩家误认为同一种物品。
4. 运行态图案必须使用实心、高饱和、无文字的几何 SVG至少覆盖圆形、三角形、菱形、方形、五角星、六边形、胶囊、心形、梯形、平行四边形等多种轮廓外层命中按钮不得再显示半透明气泡底。
5. 水果题材的相对尺寸由后端权威半径决定,首版要求西瓜明显大于苹果,苹果、橙子、桃子等中型水果大于葡萄、李子、青柠等小型水果;前端不得自行改写规则半径,只负责按快照表现。
6. 后续接入真实题材图片素材前,必须另补资产生成方案。
## 9.4 难度
首版 `difficulty` 只作为布局和生成算法参数。
后端需要保留参数入口,但难度公式先保持简洁:
1. 难度越高,物品尺寸可整体略小。
2. 难度越高,堆叠层级可略深。
3. 难度越高,首屏可直接三消的可见组合可略少。
4. 同一局内允许有轻微尺寸差异,但每个物品仍必须完整落在圆形空间内。
具体数值不在 A0 冻死,由 B1 领域 crate 分支给出首版常量并通过测试覆盖。
---
## 10. api-server HTTP facade
## 10.1 创作链
```text
POST /api/creation/match3d/sessions
GET /api/creation/match3d/sessions/:sessionId
POST /api/creation/match3d/sessions/:sessionId/messages
POST /api/creation/match3d/sessions/:sessionId/messages/stream
POST /api/creation/match3d/sessions/:sessionId/compile
```
说明:
1. 同步消息接口用于普通提交。
2. 流式接口复用现有 Agent SSE 基建。
3. `messages` 只写 user messageLLM 推理由 `api-server` 完成后 finalize 到 SpacetimeDB。
4. `compile` 不生成额外素材,只生成 Match3D 草稿和作品 draft。
## 10.2 作品链
```text
PATCH /api/creation/match3d/works/:profileId
POST /api/creation/match3d/works/:profileId/publish
GET /api/creation/match3d/works
GET /api/creation/match3d/works/:profileId
```
首版发布不要求试玩通关。
## 10.3 运行态链
```text
POST /api/runtime/match3d/works/:profileId/runs
GET /api/runtime/match3d/runs/:runId
POST /api/runtime/match3d/runs/:runId/click
POST /api/runtime/match3d/runs/:runId/stop
POST /api/runtime/match3d/runs/:runId/restart
POST /api/runtime/match3d/runs/:runId/time-up
```
`time-up` 可后置;若不单独实现,`get` 或下一次 `click` 必须能懒确认超时失败。
## 10.4 错误语义
HTTP 层使用现有 API envelope。
建议错误码:
1. `MATCH3D_SESSION_NOT_FOUND`
2. `MATCH3D_WORK_NOT_FOUND`
3. `MATCH3D_RUN_NOT_FOUND`
4. `MATCH3D_INVALID_CONFIG`
5. `MATCH3D_PUBLISH_BLOCKED`
6. `MATCH3D_RUN_VERSION_CONFLICT`
7. `MATCH3D_RUN_ALREADY_FINISHED`
---
## 11. 前端落点
## 11.1 contracts 与 service
新增:
```text
src/services/match3d-creation/
src/services/match3d-works/
src/services/match3d-runtime/
```
分别负责 Agent/草稿、作品/发布、运行态请求。
## 11.2 组件
新增:
```text
src/components/match3d-creation/
src/components/match3d-result/
src/components/match3d-runtime/
```
## 11.3 平台入口
需要接入:
1. `src/components/platform-entry/platformEntryCreationTypes.ts`
2. `src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`
3. `src/components/platform-entry/usePlatformCreationAgentFlowController.ts`
入口展示:
1. 名称:`抓大鹅`
2. 子标题:`经典消除玩法`
## 11.4 运行态 UI
首版运行态必须移动端优先:
1. 圆形空间占据主要区域。
2. 备选栏固定 `7` 格。
3. 倒计时清晰但不遮挡物品。
4. 物品点击区域稳定,不因动画造成布局跳动。
5. 胜利/失败结算使用独立面板,不在当前面板下方展开。
## 11.5 本地 mock 口径
F3 运行态即时反馈分支可以先用本地 mock snapshot 开发,但必须满足:
1. mock 类型来自 `packages/shared/src/contracts/match3dRuntime.ts`
2. mock 字段不得脱离 A0 文档。
3. 接入真实 API 时删除或降级为测试 fixture。
---
## 12. 并行开发包
## 12.1 第二波并行
### B1 + B2领域 crate 与 shared contracts
写入范围:
1. `server-rs/crates/module-match3d/`
2. `server-rs/Cargo.toml`
3. `server-rs/crates/shared-contracts/src/match3d_*.rs`
4. `packages/shared/src/contracts/match3d*.ts`
交付:
1. 领域规则单测。
2. DTO 编译通过。
3. 不接 SpacetimeDB。
### B3SpacetimeDB 表与 procedure
写入范围:
1. `server-rs/crates/spacetime-module/src/match3d/`
2. `server-rs/crates/spacetime-module/src/lib.rs`
3. `server-rs/crates/spacetime-module/src/migration.rs`
4. 生成后的 bindings 由后续 B4 处理。
交付:
1. 表和 procedure 定义。
2.`module-match3d` 规则接线。
3. `spacetime build` 或仓库现有等价脚本通过。
B3 当前落地状态:
1. `server-rs/crates/spacetime-module/src/match3d/` 已承载 Match3D 的表、procedure 输入输出类型和 procedure 实现,并由 `server-rs/crates/spacetime-module/src/lib.rs` 挂载导出。
2. `migration.rs` 已纳入 `match3d_agent_session``match3d_agent_message``match3d_work_profile``match3d_runtime_run` 四张表,后续字段变更继续按 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` 追加兼容字段。
3. 运行态 `start_match3d_run``click_match3d_item``stop_match3d_run``finish_match3d_time_up` 通过适配层调用 `module-match3d` 的领域规则SpacetimeDB 层只负责归属校验、事务写入、权威快照持久化和 procedure JSON 返回。
4. B3 对外仍返回当前首版快照字段 `snapshotVersion / clientSnapshotVersion` 对应语义;`module-match3d` 内部的 `board_version` 只在适配层中转换,避免影响并行中的 B4/F3 接入。
5. SpacetimeDB module 的有效验收命令是 `spacetime build --module-path crates/spacetime-module`;不要用普通 native `cargo test -p spacetime-module` 作为验收口径,因为该 crate 会链接 SpacetimeDB 宿主符号。
### F1创作入口与 Agent UI
写入范围:
1. `src/components/platform-entry/`
2. `src/components/match3d-creation/`
3. `src/services/match3d-creation/`
交付:
1. 平台入口可见。
2. Agent 工作区能收集题材、需要消除次数和难度。
3. 可用 mock client等待 B5 接口。
### F3运行态即时反馈 UI
写入范围:
1. `src/components/match3d-runtime/`
2. `src/services/match3d-runtime/`
交付:
1. 圆形空间、2D 物品、`7` 格备选栏。
2. 点击命中、飞入、入槽、三消、腾格、胜负过渡。
3. 后端确认失败时的回滚和快照校正逻辑。
4. 先用 mock snapshot。
## 12.2 第三波并行
### B4 + B5spacetime-client 与 api-server facade
写入范围:
1. `server-rs/crates/spacetime-client/src/match3d.rs`
2. `server-rs/crates/spacetime-client/src/lib.rs`
3. `server-rs/crates/api-server/src/match3d.rs`
4. `server-rs/crates/api-server/src/app.rs`
5. `server-rs/crates/api-server/src/main.rs` 如需注册模块
交付:
1. HTTP facade 可调用 SpacetimeDB procedure。
2. 创作、作品、运行态接口返回 shared-contract DTO。
3. 后端定向测试通过。
### F2结果页与发布
写入范围:
1. `src/components/match3d-result/`
2. `src/services/match3d-works/`
3. 创作中心作品恢复相关最小接线。
交付:
1. 编辑游戏名称、标签、封面图。
2. 试玩入口。
3. 发布入口。
### F4平台分发最小接入
写入范围:
1. 创作中心作品货架。
2. 首页/分类/广场卡片映射。
3. 作品详情启动运行态入口。
交付:
1. 已发布 Match3D 作品可进入平台列表。
2. 卡片可进入详情或运行态。
## 12.3 最后集成
### Q1集成验收
交付:
1. 创作到发布到试玩主链通过。
2. 运行态点击、入槽、三消、失败、胜利通过。
3. 移动端视口检查通过。
4. `npm run api-server:maincloud` 通过。
5. 对应测试与 `npm run check:encoding` 通过。
---
## 13. 合并顺序
建议合并顺序:
1. A0本文档。
2. B1 + B2领域 crate 与 shared contracts。
3. B3SpacetimeDB 表和 procedure。
4. B4 + B5spacetime-client 与 api-server facade。
5. F1 / F2 / F3前端创作、结果页、运行态。
6. F4平台分发。
7. Q1集成收口。
如果 F1/F3 先完成,应只以 mock client 保持可编译,不直接修改后端合同。
---
## 14. 验收命令
后续编码分支按改动范围执行。
文档分支:
```powershell
npm run check:encoding -- docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md docs/technical/README.md
```
后端分支:
```powershell
cargo test -p module-match3d
cargo test -p shared-contracts
npm run api-server:maincloud
npm run check:encoding
```
SpacetimeDB 分支按仓库现有发布脚本执行,并在需要生成绑定时使用 `spacetime generate` 或仓库封装脚本。不得手写生成文件。
前端分支:
```powershell
npm run check:encoding
npm run typecheck
```
若新增定向测试,应补跑对应 `vitest`
---
## 15. 一句话结论
Match3D 首版按独立玩法域落地:前端负责所有局内即时反馈以保证手感,后端通过 SpacetimeDB procedure 权威确认规则和成绩api-server 只暴露稳定 HTTP facade后续并行分支必须围绕本文冻结的 DTO、表、procedure 和路由推进。

View File

@@ -0,0 +1,34 @@
# 抓大鹅创作入口开放与错误隔离 2026-05-01
## 1. 背景
抓大鹅 Match3D 玩法域已完成当前 demo 主链接入,本轮恢复创作页入口,使玩家可以从创作中心直接进入抓大鹅共创工作台。同时,平台首页会并行读取 RPG、拼图、抓大鹅等公开广场数据公开广场接口未就绪、空表或临时失败不应污染创作入口错误态也不应表现成登录异常。
## 2. 落地边界
本轮只调整平台创作入口展示、点击分流与公开广场错误隔离:
1. `PLATFORM_CREATION_TYPES``match3d` 保持展示,标题仍为 `抓大鹅`
2. `match3d` 的副标题显示 `经典消除玩法`badge 显示 `可创建`
3. `match3d.locked` 设为 `false`,创作页首屏卡片和创作类型弹层均可点击。
4. 首屏卡片的 `handleCreationHubCreateType('match3d')` 必须走登录保护后调用 `openMatch3DAgentWorkspace()`
5. 创作类型弹层的 `onSelectMatch3D` 必须走同一条登录保护与工作台打开链路。
6. 公开抓大鹅广场读取失败只清空抓大鹅公开列表,不写入 `match3dError`,避免把公开数据失败展示为创作工作台错误。
7. RPG 公开作品广场读取失败只降级为空列表,不提升为整个平台错误;私有作品库、创作作品列表等受保护请求失败仍保留错误提示。
## 3. 非目标
1. 不删除 `src/components/match3d-creation/``src/services/match3d-creation/` 或已完成的 Match3D 玩法域代码。
2. 不修改 SpacetimeDB 表、procedure、bindings 或 `migration.rs`
3. 不改变已发布抓大鹅作品的详情、运行态和后续恢复入口能力。
4. 不在本轮补做公开广场接口的后端业务兜底;前端只对公开读取失败做非阻塞降级。
## 4. 验收点
1. 创作页能看到 `抓大鹅` 卡片。
2. 该卡片显示 `经典消除玩法`,且按钮可点击。
3. 登录态点击创作页首屏 `抓大鹅` 卡片后进入抓大鹅共创工作区。
4. 未登录点击 `抓大鹅` 入口时弹出登录面板,不静默吞掉点击。
5. 抓大鹅公开广场读取失败时,创作页不显示 `读取抓大鹅广场失败`,抓大鹅入口仍可进入。
6. RPG 公开作品广场读取失败时,首页不显示阻塞性的 `读取作品广场失败`,创作页仍可正常打开。
7. 相关测试、类型检查和编码检查通过。

View File

@@ -0,0 +1,113 @@
# 抓大鹅 Match3D 领域规则与共享契约 Stage1 方案
日期:`2026-04-30`
## 1. 文档目的
本文件承接 [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md),只冻结 B1 + B2 开发范围:
1. 新增 `module-match3d` 纯领域 crate。
2. 新增 Rust shared contracts。
3. 新增 TypeScript shared contracts。
本阶段不实现 SpacetimeDB 表、procedure、`spacetime-client` 调用封装、`api-server` facade 和前端页面。
## 2. Stage1 边界
## 2.1 本阶段做
1. 领域层定义创作配置、作品草稿、作品 profile、运行态快照、物品、托盘、点击确认结果。
2. 领域层提供纯函数:
- 校验创作配置
- 编译默认草稿
- 校验发布字段
- 按确定性 seed 生成初始运行态
- 刷新 2D 可点击快照
- 确认点击、入槽、三消、胜利、托盘满失败
- 确认倒计时失败
3. Rust / TypeScript shared contracts 提供前后端对齐的请求与响应 DTO。
4. 运行态采用“前端即时反馈 + 后端权威确认”契约:
- 前端可先播放点击、飞入、入槽、三消、腾格和胜负过渡。
- 后端确认后返回权威快照。
- 后端拒绝或快照版本不一致时,前端按权威快照回滚或校正。
## 2.2 本阶段不做
1. 不新增 SpacetimeDB 表。
2. 不新增 SpacetimeDB procedure。
3. 不生成新的 SpacetimeDB bindings。
4. 不新增 `api-server` 路由。
5. 不接入平台入口、结果页或运行态 UI。
6. 不接入真实图片生成。
7. 不做排行榜与后续关卡推荐。
## 3. 领域 crate 设计
新增:
```text
server-rs/crates/module-match3d
```
该 crate 是纯领域层,不读写数据库,不访问网络,不依赖浏览器或文件系统。
本阶段虽然不落 SpacetimeDB 表和 procedure但领域模型已经为后续 SpacetimeDB 接入预留 `spacetime-types` feature。后续在 `spacetime-module` 内使用这些类型时,仍必须遵守 reducer 确定性、`ctx.sender()` 鉴权和表结构迁移约束。
核心类型:
1. `Match3DCreatorConfig`
2. `Match3DResultDraft`
3. `Match3DWorkProfile`
4. `Match3DRunSnapshot`
5. `Match3DItemSnapshot`
6. `Match3DTraySlot`
7. `Match3DClickConfirmation`
核心函数:
1. `build_creator_config`
2. `compile_result_draft`
3. `validate_publish_requirements`
4. `create_work_profile`
5. `publish_work_profile`
6. `start_run_with_seed_at`
7. `confirm_click_at`
8. `resolve_run_timer_at`
## 4. 即时反馈与权威确认
本阶段将点击处理明确拆成两层:
1. 前端即时反馈层
- 读取后端快照中的 `boardVersion`、物品位置、层级、半径和 `clickable`
- 本地做命中检测和动画。
- 立即表现飞入、入槽、三消和胜负过渡。
2. 后端权威确认层
- 校验 `runId``itemInstanceId`、运行态状态和物品是否仍可点击。
- 重新计算入槽、三消、托盘满失败和胜利。
- 返回最新 `Match3DRunSnapshot`
-`boardVersion` 帮前端识别是否需要校正。
`Flying` 只作为前端表现态,不要求后端逐帧落库。后端只确认物品是否已从 `InBoard` 进入 `InTray``Cleared`
运行态领域内部使用 `board_version` 表示权威快照版本HTTP 与 TypeScript shared contracts 对外使用 `snapshotVersion` / `clientSnapshotVersion`,由后续 `api-server` facade 做字段映射。
## 5. 生成规则 Stage1 口径
1. `clearCount` 必须是正整数。
2. `totalItemCount = clearCount * 3`
3. 难度范围为 `1~10`
4. 首版内置水果题材视觉 key 和颜色形状兜底视觉 key。
5. 当题材包含水果语义时,使用水果视觉 key其他题材使用颜色形状兜底 key。
6.`clearCount > 10` 时,复用视觉 key并保证每种物品数量仍为 `3` 的倍数。
7. 初始布局使用确定性 seed 生成圆形空间内的 2D 坐标。
8. 坐标使用 `0~1` 归一化舞台坐标,圆心为 `(0.5, 0.5)`;生成时必须保证 `distance((x, y), (0.5, 0.5)) + radius <= 0.5 - safeMargin`,避免物品被圆形边界压住或裁切。
9. 可点击判定只做 2D 近似:若物品被更高层物品完全覆盖,则不可点击;否则可点击。
## 6. 验收
1. `cargo test -p module-match3d` 通过。
2. `cargo test -p shared-contracts match3d` 通过。
3. `npm run check:encoding` 覆盖新增中文文档和新增源码。
4. 本阶段不要求运行 `npm run api-server:maincloud`因为未修改后端运行服务入口、SpacetimeDB 表或 `api-server` facade。

View File

@@ -0,0 +1,91 @@
# 抓大鹅 Match3D F1 创作入口与 Agent UI 落地记录 2026-04-30
## 1. 阶段边界
本文件承接《MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md》的 F1 包。
F1 只处理前端创作入口、Agent 工作区和等待后端 B5 facade 前的 mock client。它不实现运行态规则不修改 SpacetimeDB 表,不接 `api-server` 路由。
## 2. 本阶段写入范围
1. `src/components/platform-entry/`
2. `src/components/match3d-creation/`
3. `src/services/match3d-creation/`
4. `packages/shared/src/contracts/match3dAgent.ts`
其中 `packages/shared/src/contracts/match3dAgent.ts` 作为 F1 与后续 B5 的 DTO 对齐点F1 mock client 不自建脱离共享契约的临时类型。
## 3. 入口接入
平台入口新增可见创作类型:
```text
id: match3d
title: 抓大鹅
subtitle: 经典消除玩法
badge: 可创建
```
入口来源统一走 `getVisiblePlatformCreationTypes()`,因此创作首页首屏卡带与“选择创作类型”弹层会同时出现抓大鹅。
## 4. Agent 工作区
新增 `Match3DAgentWorkspace`,复用通用 `CreationAgentWorkspace`
Agent 只收集三类锚点:
1. 题材主题。
2. 需要消除次数。
3. 难度。
工作区支持参考图片上传入口。图片在 F1 中先以 Data URL 形式随消息 payload 带给 mock clientB5 接入后由后端 facade 替换为正式资产上传与引用。
UI 中不默认展示玩法规则长文,只展示进度、锚点、聊天内容和必要按钮。
## 5. mock client
新增 `src/services/match3d-creation/match3dCreationClient.ts`
mock client 提供:
1. `createMatch3DCreationSession`
2. `getMatch3DCreationSession`
3. `streamMatch3DCreationMessage`
4. `executeMatch3DCreationAction`
mock 行为:
1. 创建本地会话。
2. 从中文输入中提取题材、消除次数和难度。
3. 支持“自动配置”。
4. 当三项配置完整时允许执行 `match3d_compile_draft`
5. 编译后返回 `draft_ready` 会话和草稿。
## 6. 结果承接
F1 新增 `Match3DDraftReadyView` 作为草稿生成后的临时承接页,只展示草稿基础信息并允许返回 Agent 修改。
正式结果页的基础信息编辑、封面图、试玩、发布由 F2 接入F1 不在这里模拟发布。
## 7. 后续替换点
B5 完成后,只需要把 `match3dCreationClient` 的本地 Map mock 替换为 HTTP/SSE facade
```text
POST /api/creation/match3d/sessions
GET /api/creation/match3d/sessions/:sessionId
POST /api/creation/match3d/sessions/:sessionId/messages/stream
POST /api/creation/match3d/sessions/:sessionId/compile
```
`PlatformEntryFlowShellImpl``Match3DAgentWorkspace` 不应再改一轮业务字段。
## 8. 验收口径
1. 创作首页能看到“抓大鹅 / 经典消除玩法”。
2. 弹层选择“抓大鹅”能进入 Agent 工作区。
3. 输入题材、消除次数、难度后进度到 `100%`
4. 点击“生成结果页”进入草稿承接页。
5. 可从草稿承接页返回 Agent 修改。
6. `npm run check:encoding` 通过。
7. `npm run typecheck` 通过。

View File

@@ -0,0 +1,394 @@
# 抓大鹅 Match3D F2 结果页与发布技术方案
日期:`2026-04-30`
## 1. 文档目的
本文件承接 [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md),只冻结 F2 开发范围:
1. Match3D 待发布结果页。
2. 作品基础信息编辑。
3. 发布前试玩入口。
4. 发布入口。
5. 已发布作品二次编辑恢复口径。
本阶段不实现运行态即时反馈 UI不实现 SpacetimeDB 表与 procedure不实现 `api-server` facade。F2 可以先基于 shared contracts 与 mock client 开发,等待 B4+B5 接入真实 HTTP。
---
## 2. 前置依赖
F2 依赖以下已冻结文档:
1. PRD[AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](../prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md)
2. A0[MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md)
3. B1+B2[MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md](./MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md)
F2 可在 B4+B5 之前并行开发,但必须遵守 B2 的 TypeScript contract不得在前端私自扩字段。
---
## 3. 本阶段做
1. 新增 Match3D 结果页组件目录。
2. 新增 Match3D works service 目录。
3. 展示草稿配置摘要:
- 题材主题
- 需要消除次数
- 难度
- 参考图片预览
4. 支持编辑发布基础信息:
- 游戏名称
- 标签
- 封面图
5. 支持发布前试玩入口。
6. 支持试玩中止后回到结果页继续编辑。
7. 支持发布入口。
8. 支持已发布作品二次编辑的前端恢复路径。
---
## 4. 本阶段不做
1. 不生成题材物品素材。
2. 不生成额外封面图;封面图只接收已有图片、上传图片或后端已有占位结果。
3. 不要求试玩通关后才能发布。
4. 不实现运行态点击、飞入、三消等即时反馈。
5. 不实现首页、分类页和广场投影。
6. 不实现排行榜。
7. 不在 UI 中默认展示玩法规则说明长文。
8. 不把发布校验只写在前端;前端只做即时提示,后端 publish gate 是最终门槛。
---
## 5. 文件落点
## 5.1 前端组件
新增:
```text
src/components/match3d-result/
```
建议文件:
```text
src/components/match3d-result/Match3DResultView.tsx
src/components/match3d-result/Match3DResultView.test.tsx
src/components/match3d-result/index.ts
```
如组件变大,可后续拆分:
```text
Match3DResultHeader.tsx
Match3DResultBasicsForm.tsx
Match3DResultConfigPreview.tsx
Match3DResultPublishPanel.tsx
```
首版不要过早拆太多文件,优先保持可读和低冲突。
## 5.2 前端 service
新增:
```text
src/services/match3d-works/
```
建议文件:
```text
src/services/match3d-works/match3dWorksClient.ts
src/services/match3d-works/index.ts
```
F2 只负责 works 维度:
1. 读取作品详情。
2. 更新作品基础信息。
3. 发布作品。
4. 删除作品可后置,若 F4 需要再补。
运行态启动接口归 `src/services/match3d-runtime/`F2 只调用上层传入的 `onStartTestRun`
---
## 6. shared contracts 使用
F2 只消费 B2 已冻结的 TypeScript contract
```text
packages/shared/src/contracts/match3dWorks.ts
packages/shared/src/contracts/match3dAgent.ts
packages/shared/src/contracts/match3dRuntime.ts
```
必要类型:
1. `Match3DWorkProfile`
2. `Match3DWorkSummary`
3. `Match3DWorkUpdateRequest`
4. `Match3DPublishRequest`
5. `Match3DPublishResult`
6. `Match3DCompileDraftResult`
7. `Match3DCreatorConfig`
F2 不新增独立的前端私有数据结构来表达作品真相;只允许使用局部表单状态承载未保存输入。
---
## 7. 结果页 props contract
建议 `Match3DResultView` props
```ts
type Match3DResultViewProps = {
profile: Match3DWorkProfile;
draft?: Match3DCompileDraftResult | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onStartTestRun: (profile: Match3DWorkProfile) => void;
onPublish: (payload: Match3DPublishRequest) => void;
onSaved?: (profile: Match3DWorkProfile) => void;
};
```
说明:
1. `profile` 是结果页当前作品真相源。
2. `draft` 只用于展示草稿生成附加信息;不能覆盖 `profile` 的发布字段。
3. `onStartTestRun` 进入 F3/B5 运行态链路。
4. `onPublish` 可以先由 mock client 实现B5 完成后替换为真实 HTTP。
5. `onSaved` 用于把自动保存后的 profile 回写给上层流程控制器。
---
## 8. 页面内容顺序
结果页保持单列表,不做多 Tab。
固定顺序:
1. 顶部返回与保存状态。
2. 封面图。
3. 游戏名称。
4. 标签。
5. 题材主题。
6. 需要消除次数。
7. 难度。
8. 参考图片预览。
9. 试玩按钮。
10. 发布按钮。
UI 只呈现必要信息,不在页面中展示玩法规则说明长文。
---
## 9. 字段编辑规则
## 9.1 游戏名称
1. 必填。
2. 首版建议前端限制 `1~30` 个中文字符等价长度。
3. 默认值来自 Agent 确认题材或系统生成草稿。
## 9.2 标签
1. 必填。
2. 首版建议 `3~6` 个标签,与拼图发布门槛保持一致。
3. 输入支持中文逗号、英文逗号、顿号、换行拆分。
4. 前端需要去重和去空格。
## 9.3 封面图
1. 必填。
2. F2 可先复用参考图片、占位封面或用户上传图。
3. 图片真实存储由现有资产链或后续 B5 facade 处理。
4. 前端不得把本地临时 blob URL 当作已发布封面真相。
## 9.4 题材主题、需要消除次数、难度
首版结果页允许展示并可编辑这些配置。
修改后必须同步保存到作品 profile
1. `themeText`
2. `clearCount`
3. `difficulty`
注意:
1. `clearCount` 必须为正整数。
2. `difficulty` 必须在 `1~10`
3. 修改配置后,下一次试玩必须基于最新保存配置启动。
---
## 10. 自动保存
F2 建议实现自动保存,口径参考拼图结果页:
1. 输入变更后 `600ms` debounce。
2. 只保存结果页可编辑字段。
3. 保存中展示轻量状态。
4. 保存失败展示轻量错误,不弹长说明。
5. 发布前必须等待最后一次保存完成,或发布 payload 直接携带当前表单字段。
建议状态:
```ts
type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
```
---
## 11. 发布门槛
前端即时 blocker
1. 游戏名称为空。
2. 标签数量不在 `3~6`
3. 封面图为空。
4. `clearCount` 不是正整数。
5. `difficulty` 不在 `1~10`
后端 publish gate 是最终门槛,前端不得绕过。
发布不要求试玩通关。
---
## 12. 试玩入口
结果页提供“试玩”入口。
行为:
1. 点击试玩前先保存当前表单。
2. 保存成功后调用 `onStartTestRun(profile)`
3. 上层进入 Match3D 运行态。
4. 运行态停止或返回后,回到同一个结果页继续编辑。
F2 不实现运行态本身;只冻结结果页如何发起试玩。
---
## 13. 发布接口
F2 service 建议接口:
```ts
const MATCH3D_WORKS_API_BASE = '/api/creation/match3d/works';
export async function getMatch3DWorkDetail(profileId: string): Promise<Match3DWorkDetailResponse>;
export async function updateMatch3DWork(
profileId: string,
payload: Match3DWorkUpdateRequest,
): Promise<Match3DWorkMutationResponse>;
export async function publishMatch3DWork(
profileId: string,
payload: Match3DPublishRequest,
): Promise<Match3DPublishResult>;
```
后续 B5 必须提供同名 HTTP facade 或在 service 层做最小适配。
---
## 14. Mock client 口径
F2 可以在真实 B5 接口完成前使用 mock client。
要求:
1. mock 数据必须来自 shared contracts。
2. mock profile 字段必须覆盖发布必填项。
3. mock publish 只能返回“可发布成功”的本地结果,不得伪造平台广场投影。
4. B5 接入后mock 只能保留为测试 fixture。
---
## 15. 已发布作品二次编辑
进入自己已发布 Match3D 作品时,结果页应支持二次编辑。
规则:
1. 优先通过 `sourceSessionId` 恢复原创作 session。
2. 如果没有 session则通过 `profileId` 读取作品详情进入结果页。
3. 二次发布不得创建新作品,必须覆盖同一 `profileId`
4. 不清零 `playCount`
5. 不改变作品归属。
---
## 16. 与其它分支的接口边界
## 16.1 依赖 F1
F1 负责创建会话和 Agent UI。F2 接收 F1 编译出的 `profile / draft`,不重复实现 Agent 对话。
## 16.2 依赖 F3
F3 负责运行态 UI。F2 只提供 `onStartTestRun` 入口。
## 16.3 依赖 B5
B5 负责真实 HTTP facade。F2 的 service path 和 DTO 必须按本文冻结,避免后续替换 mock 时改组件结构。
## 16.4 依赖 F4
F4 负责首页、分类页和广场分发。F2 发布成功后只需要把返回 profile 交给上层;不直接刷新广场列表。
---
## 17. 测试要求
建议新增:
```text
src/components/match3d-result/Match3DResultView.test.tsx
```
覆盖:
1. 展示游戏名称、标签、封面图、题材、需要消除次数和难度。
2. 游戏名称为空时发布按钮阻断。
3. 标签数量不足时发布按钮阻断。
4. `clearCount` 非正整数时发布按钮阻断。
5. `difficulty` 超出 `1~10` 时发布按钮阻断。
6. 点击试玩前触发保存。
7. 发布不要求试玩通关。
service 测试可在 B5 接入后补齐。
---
## 18. 验收命令
F2 文档分支:
```powershell
npm run check:encoding -- docs/technical/MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md docs/technical/README.md
```
F2 前端实现分支:
```powershell
npm run check:encoding
npm run typecheck
```
如新增组件测试,补跑对应 `vitest`
---
## 19. 一句话结论
F2 只负责把 Match3D 草稿变成可编辑、可试玩、可发布的作品工作台;它必须复用平台结果页和发布体验,发布不要求试玩通关,并为 B5 真实后端接口与 F3 运行态试玩入口保留清晰边界。

View File

@@ -0,0 +1,146 @@
# 抓大鹅 Match3D Q1 集成验收与收口记录 2026-05-01
## 1. 本轮目标
Q1 不新增玩法规则,只把第一至第三波已经形成的 Match3D 独立玩法域接成可跑主链:
1. 创作 Agent 前端从本地 mock 切到 `api-server` HTTP/SSE facade。
2. 结果页从临时草稿承接页升级为可编辑、可保存、可试玩、可发布的作品工作台。
3. 试玩运行态从结果页启动真实 `/api/runtime/match3d/*` run并继续保持“前端即时反馈 + 后端权威确认”。
4. 创作中心至少能读取当前用户 Match3D 作品列表,并支持打开草稿继续编辑。
本轮结论:已按合并顺序完成 Q1 主链集成。第一至第三波的主体能力均已落到工程Q1 已把它们串成“创作 Agent -> 结果页保存/发布/试玩 -> 公开详情/作品号搜索 -> 运行态”的最小可跑链路。
## 2. 第一至第三波验收口径
### 第一波 A0
文档已存在:
```text
docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md
```
结论:已完成。该文档冻结了独立玩法域、表与 procedure、HTTP facade、前端即时反馈协议和合并顺序。
### 第二波 B1 + B2
已落点:
```text
server-rs/crates/module-match3d/
server-rs/crates/shared-contracts/src/match3d_*.rs
packages/shared/src/contracts/match3d*.ts
```
结论:已完成。领域 crate、Rust DTO、TypeScript DTO 已存在,并已通过 Q1 定向复跑。
### 第二波 B3
已落点:
```text
server-rs/crates/spacetime-module/src/match3d/
server-rs/crates/spacetime-module/src/migration.rs
```
结论:已完成。四张 Match3D 表已纳入 migrationprocedure 已接 `module-match3d` 领域规则。本轮不改表结构,不需要新增 migration。
### 第二波 F1
已落点:
```text
src/components/match3d-creation/
src/services/match3d-creation/
src/components/platform-entry/
```
结论:已完成并已接入 Q1。入口与 Agent UI 已存在,`match3dCreationClient` 已从本地 mock 切到 `api-server` HTTP/SSE facade本地 mock 只保留在测试夹具和 `/match3d` playground 运行调试链路中。
### 第二波 F3
已落点:
```text
src/components/match3d-runtime/
src/services/match3d-runtime/match3dLocalRuntime.ts
src/Match3DPlaygroundApp.tsx
```
结论:已完成并已接入 Q1。圆形空间、7 格备选栏、乐观点击、三消反馈、结算面板和回滚校正语义已存在Q1 已补真实 runtime client 与平台入口接线。
### 第三波 B4 + B5
已落点:
```text
server-rs/crates/spacetime-client/src/match3d.rs
server-rs/crates/api-server/src/match3d.rs
server-rs/crates/api-server/src/app.rs
```
结论已完成。HTTP facade 路由已注册Q1 前端已按这些稳定路由接入。
### 第三波 F2
目标落点:
```text
src/components/match3d-result/
src/services/match3d-works/
```
结论:已完成并已接入 Q1。新增 `Match3DResultView``match3d-works` service支持基础信息编辑、保存、发布、试玩入口发布仍要求封面和标签门槛试玩只要求基础配置可保存。
### 第三波 F4
结论:已完成 Q1 最小平台分发。创作中心作品货架、公开卡片映射、统一作品详情、`M3-xxxxxxxx` 作品号搜索和详情页启动运行态已接入;排行榜、点赞、改造统计和更复杂推荐策略仍留到后续优化。
## 3. Q1 本轮代码落点
本轮实际落点:
1. `src/services/match3d-creation/`:替换本地 mock 为 HTTP/SSE facade。
2. `src/services/match3d-works/`:新增作品读取、保存、发布 service。
3. `src/services/match3d-runtime/`:新增真实运行态 service保留本地 playground mock。
4. `src/components/match3d-result/`:新增结果页组件。
5. `src/components/platform-entry/`:串起结果页、试玩 run、作品列表刷新。
6. `src/components/custom-world-home/` 与展示映射:扩展 Match3D 作品货架、公开卡片、统一详情页。
7. `src/services/publicWorkCode.ts``src/routing/appPageRoutes.ts`:新增 `M3-xxxxxxxx` 作品号与公开详情路由识别。
8. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`:补齐 Match3D 作品号搜索启动运行态回归,并同步统一详情页后的 RPG/Big Fish 旧测试语义。
## 4. 验收命令
本轮已通过:
```powershell
npm test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/routing/appPageRoutes.test.ts src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx --reporter=verbose --silent
```
结果:`6 passed``65 passed`
```powershell
cargo test -p module-match3d
cargo test -p shared-contracts
cargo check -p api-server
cargo check -p spacetime-client
npm run check:encoding
```
结果:
1. `module-match3d``7 passed`
2. `shared-contracts``47 passed`
3. `api-server``cargo check` 通过。
4. `spacetime-client``cargo check` 通过。
5. 编码检查:`2804 file(s)` 通过。
## 5. 本轮不做与遗留风险
1. 不改 Match3D 表结构。
2. 不扩展排行榜、点赞、二次创作统计。
3. 不把 Match3D 公开广场并入更复杂的推荐、排行和运营榜单策略。
4. 不删除 `/match3d` 本地 playground它作为开发调试入口继续保留。
5. 全量 `npm run typecheck` 曾存在非 Match3D 既有阻塞,本轮以 Q1 定向测试和后端定向检查作为集成验收口径。
6. Maincloud 运行态仍依赖当前 SpacetimeDB 环境稳定性;如 `npm run api-server:maincloud` 现场遇到订阅 HTTP 500应按 Maincloud/SpacetimeDB 联调链路单独排查。

View File

@@ -0,0 +1,131 @@
# 抓大鹅 Match3D B4+B5 spacetime-client 与 api-server facade 落地记录
日期:`2026-04-30`
## 1. 本阶段目标
本文件记录 B4+B5 的技术落地范围:把 B3 已生成的 Match3D SpacetimeDB procedure 接到 `spacetime-client`,再通过 `api-server` 暴露给前端使用的 HTTP facade。
本阶段不改 SpacetimeDB 表结构,不新增 migration不接入真实题材素材生成也不改前端即时反馈实现。
## 2. 已落地范围
### 2.1 SpacetimeDB bindings
使用仓库封装脚本重新生成 bindings
```powershell
npm run spacetime:generate
```
Windows 下 SpacetimeDB CLI 可能在 Rust bindings 已生成后输出 `Could not format generated files: 文件名或扩展名太长。 (os error 206)`。脚本已调整为:当 CLI 退出码为 `0` 且只是格式化警告时继续同步生成文件。
生成文件仍视为机器产物,禁止手写修改。
### 2.2 spacetime-client facade
新增:
```text
server-rs/crates/spacetime-client/src/match3d.rs
```
并在 `spacetime-client/src/lib.rs` 导出 `match3d` 模块与 Match3D Record 类型。
已覆盖:
1. 创作会话create / get / submit message / finalize / compile draft。
2. 作品list / get detail / update / publish / delete / public gallery list。
3. 运行态start / get / click / stop / restart / time-up。
`mapper.rs` 负责把 procedure 返回的 JSON 字符串解析为稳定 Record不把 generated bindings 泄露到 `api-server`
### 2.3 api-server HTTP facade
新增:
```text
server-rs/crates/api-server/src/match3d.rs
```
并在 `main.rs` 注册模块,在 `app.rs` 挂载路由。
已挂载路由:
```text
POST /api/creation/match3d/sessions
GET /api/creation/match3d/sessions/{session_id}
POST /api/creation/match3d/sessions/{session_id}/messages
POST /api/creation/match3d/sessions/{session_id}/messages/stream
POST /api/creation/match3d/sessions/{session_id}/actions
POST /api/creation/match3d/sessions/{session_id}/compile
GET /api/creation/match3d/works
GET /api/creation/match3d/works/{profile_id}
PATCH /api/creation/match3d/works/{profile_id}
PUT /api/creation/match3d/works/{profile_id}
DELETE /api/creation/match3d/works/{profile_id}
POST /api/creation/match3d/works/{profile_id}/publish
GET /api/runtime/match3d/gallery
POST /api/runtime/match3d/works/{profile_id}/runs
GET /api/runtime/match3d/runs/{run_id}
POST /api/runtime/match3d/runs/{run_id}/click
POST /api/runtime/match3d/runs/{run_id}/stop
POST /api/runtime/match3d/runs/{run_id}/restart
POST /api/runtime/match3d/runs/{run_id}/time-up
```
`api-server` 返回 `shared-contracts` 中的 Match3D DTO前端不需要感知 SpacetimeDB 内部 JSON 快照结构。
## 3. 创作 Agent 当前口径
B5 首版先采用确定性配置抽取,不在本阶段新增真实 LLM prompt。
2026-05-01 起,抓大鹅创作入口必须按三轮 Agent 问答收集配置,不能在用户未回答前用默认值生成“已确认”回复:
1. `POST /api/creation/match3d/sessions` 创建会话后,首条 assistant 消息固定为“你想创作什么题材”。
2. 用户第一轮回复只写入题材assistant 继续问“需要消除多少次才能通关”。
3. 用户第二轮回复只写入需要消除次数assistant 继续问“如果难度是从1-10你要创作的关卡是难度几”。
4. 用户第三轮回复写入难度后assistant 才返回“已确认:...”,并把进度推进到 `100`、stage 推进到 `ReadyToCompile`
5. SpacetimeDB 当前配置快照仍要求合法数值,因此 `api-server` facade 可以在 `config_json` 内保留兜底合法值,但回复、进度和是否允许生成结果页必须以三轮问答进度为准。
6. `match3d_compile_draft` 动作只能在三项收集完成后调用 SpacetimeDB `compile_match3d_draft`,生成 draft work profile。
后续若要接真实 LLM turn应复用现有创作 Agent 公共编排,并保持 submit/finalize 两阶段职责不变。
## 4. 运行态确认协议
B5 保持 PRD 调整后的边界:
1. 前端负责点击、飞入、入槽、三消、腾格、胜负等即时表现。
2. 后端通过 `click_match3d_item``finish_match3d_time_up` 等 procedure 做权威确认。
3. HTTP response 会把 SpacetimeDB `Accepted / VersionConflict / RunFinished` 等状态归一到前端 shared contract 可消费的 `accepted / rejectReason / run` 结构。
4. `snapshotVersion` 继续作为前端即时反馈和后端确认之间的版本校验字段。
## 5. shared contract 对齐
本阶段补齐 Rust shared contract 与 TypeScript contract 的已知差异:
1. `Match3DAgentSessionSnapshotResponse` 增加 `anchorPack`
2. `Match3DResultDraftResponse` 增加 `profileId / summaryText / totalItemCount`,同时保留 `summary` 兼容结果页读取。
3. `PutMatch3DWorkRequest` 增加可选 `themeText`,结果页可编辑题材;旧请求缺省时 API 会沿用已有作品题材。
## 6. 验收命令
本阶段至少执行:
```powershell
cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml
cargo check -p api-server --manifest-path server-rs\Cargo.toml
cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml
npm run check:encoding
npm run api-server:maincloud
```
`api-server:maincloud` 是修改后端后的必跑项;如果本地缺少 Maincloud 环境或 SpacetimeDB 发布态不一致,需要在最终结果里明确说明。
## 7. 后续接入点
1. F1/F2/F3 可把 mock client 替换到上述 HTTP facade。
2. F4 平台分发可先读取 `/api/runtime/match3d/gallery` 的已发布作品列表。
3. 若后续要记录排行榜或作品播放统计,需要补 Match3D 成绩表或 play record procedure并同步更新 migration。

View File

@@ -6,33 +6,33 @@
本轮在“我的”页面的“会员充值”入口落地账户充值弹窗,包含两个页签:
1. `叙世币充值`
1. `光点充值`
2. `会员卡充值`
前端只负责展示与发起购买,套餐、价格、赠送规则、会员权益、生效时间、钱包余额与交易流水统一由 `server-rs` 后端返回。当前没有真实支付网关,本轮采用服务端模拟支付成功:创建订单后立即写入余额或会员状态,并返回最新账户中心快照。后续接入真实支付时,只替换订单支付状态推进,不改前端套餐与账户快照 contract。
## 2. 产品规则
### 2.1 叙世币充值套餐
### 2.1 光点充值套餐
| productId | 叙世币 | 金额分 | 徽标 | 说明 |
| productId | 光点 | 金额分 | 徽标 | 说明 |
| --- | ---: | ---: | --- | --- |
| `points_60` | 60 | 600 | 首充双倍 | 首充送60叙世币 |
| `points_180` | 180 | 1800 | 首充双倍 | 首充送180叙世币 |
| `points_300` | 300 | 3000 | 首充双倍 | 首充送300叙世币 |
| `points_680` | 680 | 6800 | 首充双倍 | 首充送680叙世币 |
| `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280叙世币 |
| `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280叙世币 |
| `points_60` | 60 | 600 | 首充双倍 | 首充送60光点 |
| `points_180` | 180 | 1800 | 首充双倍 | 首充送180光点 |
| `points_300` | 300 | 3000 | 首充双倍 | 首充送300光点 |
| `points_680` | 680 | 6800 | 首充双倍 | 首充送680光点 |
| `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280光点 |
| `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280光点 |
叙世币充值固定为 `¥6 / ¥18 / ¥30 / ¥68 / ¥128 / ¥328` 六个档位。全部档位参与首充双倍:用户历史上没有 `points_recharge` 流水时,本次购买到账叙世币为基础叙世币与等额赠送叙世币之和;已有充值流水后只到账基础叙世币。实际到账叙世币写入交易流水,余额以 SpacetimeDB projection 为准。
光点充值固定为 `¥6 / ¥18 / ¥30 / ¥68 / ¥128 / ¥328` 六个档位。全部档位参与首充双倍:用户历史上没有 `points_recharge` 流水时,本次购买到账光点为基础光点与等额赠送光点之和;已有充值流水后只到账基础光点。实际到账光点写入交易流水,余额以 SpacetimeDB projection 为准。
### 2.2 会员卡套餐
| productId | 类型 | 天数 | 金额分 | 权益 |
| --- | --- | ---: | ---: | --- |
| `member_month` | 月卡 | 30 | 2800 | 免叙世币回合数100每日签到加成0% |
| `member_season` | 季卡 | 90 | 7800 | 免叙世币回合数100每日签到加成100% |
| `member_year` | 年卡 | 365 | 24800 | 免叙世币回合数100每日签到加成210% |
| `member_month` | 月卡 | 30 | 2800 | 免光点回合数100每日签到加成0% |
| `member_season` | 季卡 | 90 | 7800 | 免光点回合数100每日签到加成100% |
| `member_year` | 年卡 | 365 | 24800 | 免光点回合数100每日签到加成210% |
购买会员时,如果当前会员仍有效,则从当前到期时间顺延;如果已过期或从未购买,则从当前服务端时间开始计算。状态只区分 `普通` 与已生效会员,前端不自行推断。
@@ -42,8 +42,8 @@
需要 Bearer JWT。返回
1. 当前叙世币余额、会员状态、到期时间
2. 叙世币套餐与会员套餐
1. 当前光点余额、会员状态、到期时间
2. 光点套餐与会员套餐
3. 会员权益表
4. 最近订单摘要
@@ -64,7 +64,7 @@
1. 校验 `productId`
2. 后端创建已支付订单
3. 叙世币套餐写入钱包余额与流水
3. 光点套餐写入钱包余额与流水
4. 会员套餐写入会员状态
5. 返回最新账户中心快照与订单摘要
@@ -74,15 +74,15 @@
1. “我的”页会员充值按钮打开独立弹窗,不在当前面板下方展开。
2. 弹窗顶部标题为 `账户充值`,右上角关闭。
3. 默认打开 `叙世币充值`,可切换到 `会员卡充值`
3. 默认打开 `光点充值`,可切换到 `会员卡充值`
4. 点击套餐后调用下单接口,按钮进入处理中状态,成功后刷新 `profileDashboard`
5. 弹窗内不写大段说明文案,只保留必要金额、叙世币、会员权益和状态反馈。
5. 弹窗内不写大段说明文案,只保留必要金额、光点、会员权益和状态反馈。
6. 会员卡充值区以套餐卡片优先展示周期、价格和处理状态;移动端单列,桌面端三列,权益表允许横向滚动,避免小屏挤压。
## 5. 验收
1. 普通用户打开弹窗能看到叙世币与会员套餐。
2. 叙世币购买后余额增加,流水来源为 `points_recharge`
3. 首充赠送只在首次叙世币充值时生效。
1. 普通用户打开弹窗能看到光点与会员套餐。
2. 光点购买后余额增加,流水来源为 `points_recharge`
3. 首充赠送只在首次光点充值时生效。
4. 会员购买后会员状态与到期时间立即更新。
5. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。

View File

@@ -0,0 +1,93 @@
# “我的”资料卡昵称与头像编辑落地说明
日期:`2026-04-29`
## 1. 背景
本次迭代基于 `docs/prd/MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md` 落地,但交互口径有两处收敛:
1. 昵称编辑不进入账号安全弹窗,点击昵称后的编辑按钮直接打开独立轻弹窗。
2. 头像编辑不进入通用资料抽屉,点击头像先选择本地图片,校验通过后进入头像裁剪弹窗。
资料卡仍保持清爽,不展示规则说明型长文案。
## 2. 前端交互
### 2.1 百梦号复制
1. 点击“我的”页百梦号后的复制按钮后,按钮文案临时切换为 `已复制`
2. 复制失败时临时切换为 `复制失败`
3. 状态自动恢复为 `复制`
### 2.2 昵称修改
1. 点击昵称右侧编辑按钮打开独立弹窗。
2. 弹窗内只提供昵称输入、取消、保存。
3. 弹窗面板使用平台标准不透明面板底,不复用透明轻量面板。
4. 前端先做长度与字符校验:
- `2-20` 个字符。
- 允许中文、英文、数字、下划线。
- 不允许纯空白。
5. 保存调用 `PATCH /api/profile/me`,成功后即时回写 `AuthUiContext.user`
### 2.3 头像上传与裁剪
1. 点击头像触发文件选择。
2. 前端先审核文件:
- MIME 类型仅允许 `image/jpeg``image/png``image/webp`
- 单文件不超过 `5MB`
3. 校验通过后读取为图片,打开裁剪弹窗。
4. 裁剪弹窗面板使用平台标准不透明面板底,避免底层资料卡内容透出。
5. 裁剪工具使用正方形裁剪框,支持拖动裁剪区域与缩放图片。
6. 保存时前端输出 `256x256` 的 PNG data URL调用 `PATCH /api/profile/me` 保存为账号头像。
7. 成功后资料卡头像立即展示新图。
## 3. 后端契约
### `PATCH /api/profile/me`
请求:
```json
{
"displayName": "新昵称",
"avatarDataUrl": "data:image/png;base64,..."
}
```
两个字段均可选,但至少提供一个有效字段。
响应:
```json
{
"user": {
"id": "user_00000001",
"publicUserCode": "SY-00000001",
"username": "phone_xxx",
"displayName": "新昵称",
"avatarUrl": "data:image/png;base64,...",
"phoneNumberMasked": "138****8000",
"loginMethod": "phone",
"bindingStatus": "active",
"wechatBound": false
}
}
```
## 4. 存储边界
当前头像先作为裁剪后的 `256x256` data URL 写入认证快照,保证账号资料可立即持久化和恢复。后续若接入 OSS 头像对象,应保持前端裁剪输出不变,只把后端 `avatarUrl` 从 data URL 替换为私有读代理 URL。
SpacetimeDB 正式表 `user_account` 需要增加 `avatar_url: Option<String>`,并在认证快照导入/导出、迁移导入兼容中对齐。
## 5. 验收
1. 创作页已发布作品分享按钮点击后显示 `已复制`
2. “我的”页百梦号复制按钮点击后显示 `已复制`
3. “我的”页不展示 `手机号``正常` 标签。
4. 昵称编辑成功后,资料卡与顶部账号入口同步新昵称。
5. 昵称与头像裁剪弹窗面板不透明,不能露出底层页面内容。
6. 非法头像文件不会进入裁剪流程。
7. 裁剪保存成功后,资料卡头像展示裁剪后的图片。
8. 桌面右上角账号入口与“我的”资料卡共用 `avatarUrl`,有已保存头像时展示头像图片,缺失时才回退到首字头像。

View File

@@ -1,20 +1,20 @@
# 我的 Tab 邀请与玩家社区首期落地方案
更新时间:`2026-04-25`
更新时间:`2026-05-01`
## 目标
在现有“我的”Tab 常用功能落地三个轻量入口:
在现有“我的”Tab 功能入口区(常用功能落地三个轻量入口,入口顺序固定为 `邀请好友``填邀请码``玩家社区`
1. `邀请好友`:弹出面板展示当前账号绑定的邀请码。
2. `填邀请码`:弹出面板填写邀请码,成功后邀请者与被邀请者各获得 `30` 叙世币
3. `玩家社区`:弹出面板展示微信群与 QQ 群二维码占位图,后续替换为正式图片。
1. `邀请好友`:弹出面板展示当前账号绑定的邀请码、邀请奖励规则和成功邀请用户列表
2. `填邀请码`:弹出面板填写邀请码,成功后邀请者与被邀请者各获得 `30` 光点
3. `玩家社区`:弹出面板展示微信群与 QQ 群正式二维码图片。
## 后端边界
- 邀请码、邀请关系与奖励发放全部存入 `server-rs/crates/spacetime-module`
- Axum 只做鉴权、参数转发与响应映射,不在 API 层自行计算奖励。
- 前端只读取后端状态与调用提交接口,不做本地加叙世币
- 前端只读取后端状态与调用提交接口,不做本地加光点
- 钱包余额继续复用 `profile_dashboard_state.wallet_balance`
- 奖励流水继续复用 `profile_wallet_ledger`,新增来源类型:
- `invite_inviter_reward`
@@ -43,7 +43,7 @@
- 每个用户拥有一个稳定邀请码,首次进入邀请中心时自动生成。
- 用户不能填写自己的邀请码。
- 用户最多填写一个邀请码,成功后不可修改。
- 被邀请者绑定成功后获得 `30` 叙世币
- 被邀请者绑定成功后获得 `30` 光点
- 邀请者每天最多获得 `10` 次邀请奖励,超过后关系仍可绑定,被邀请者仍获得奖励,邀请者当次不再加分。
- 每次奖励都写入钱包流水,钱包余额以后端返回为准。
@@ -51,7 +51,26 @@
### `GET /api/runtime/profile/referrals/invite-center`
返回当前用户的邀请码、邀请链接、今日奖励次数、剩余奖励次数、已绑定状态奖励参数。
返回当前用户的邀请码、邀请链接、今日奖励次数、剩余奖励次数、已绑定状态奖励参数与成功邀请用户列表
成功邀请用户列表字段:
```json
{
"invitedUsers": [
{
"userId": "user_001",
"displayName": "百梦玩家",
"avatarUrl": null,
"boundAt": "2026-05-01T08:00:00Z"
}
]
}
```
- `invitedUsers` 只包含当前账号作为邀请人的关系。
- 列表按 `boundAt` 倒序返回,最多展示最近 `20` 位成功邀请用户。
- 昵称与头像从 `user_account` 读取;缺失昵称时前端回退展示 `玩家`
### `POST /api/runtime/profile/referrals/redeem-code`
@@ -69,13 +88,18 @@
- `server-rs/crates/spacetime-module` 已新增邀请码与邀请关系表,邀请中心读取和填码绑定均通过 SpacetimeDB procedure 执行。
- `server-rs/crates/api-server` 已挂接 `/api/runtime/profile/referrals/*``/api/profile/referrals/*` 两组路由。
- 前端“我的”Tab 三个快捷入口均打开独立弹窗,玩家社区使用空白二维码占位
- 复制邀请会复制邀请码和邀请链接;填码成功后刷新个人看板叙世币
- 前端“我的”Tab 三个功能入口均打开独立弹窗,玩家社区使用 `media/social-media-group/wechat.png``media/social-media-group/qq.png` 两张正式二维码图片
- 复制邀请会复制邀请码和邀请链接;填码成功后刷新个人看板光点
- 邀请好友弹窗展示 `邀请一个用户注册,双方都可获得 30 光点。每日最多获得十次邀请奖励。`,不再展示“邀请 / 已奖 / 今日”三项统计。
- 邀请好友弹窗底部展示成功邀请用户头像和昵称列表;没有成功邀请时展示短空状态。
- “我的”页 `邀请好友` 按钮副标题展示 `双方得30光点icon``玩家社区` 按钮副标题展示 `每日领福利`
- “我的”页功能入口区不展示 `常用功能` 标题和 `快捷入口` 副标题,避免首屏重复说明类文案。
## 前端交互
- 三个入口继续放在“我的”Tab 常用功能,不新增页面。
- `邀请好友` 弹窗展示邀请码、复制按钮、邀请链接
- `填邀请码` 弹窗在未绑定时展示输入框;已绑定时展示短状态
- `玩家社区` 弹窗展示两个紧凑二维码占位区
- 弹窗文案只保留必要标签和短提示,不放长规则说明。
- 三个入口继续放在“我的”Tab 功能入口区(常用功能,不新增页面。
- `邀请好友` 弹窗展示邀请码、复制按钮、邀请奖励规则和成功邀请用户头像昵称列表
- `填邀请码` 入口只在账号注册后 `24` 小时内且尚未填写过邀请码时展示;若 `auth.user.createdAt` 缺失或解析失败,前端按已超时处理并隐藏入口
- `填邀请码` 弹窗在未绑定时展示输入框;成功绑定后刷新邀请中心与个人看板,并隐藏常用功能里的入口
- `玩家社区` 弹窗展示两个紧凑二维码图片区,保留微信群与 QQ 群短标签。
- 弹窗文案只保留必要标签和短提示;本次邀请奖励规则属于必要交易说明,固定展示在邀请码下方。

View File

@@ -1,6 +1,6 @@
# 密码登录入口历史落地设计
> 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置过密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或叙世号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。
> 2026-04-25 更新:当前产品策略已调整为“不开放密码注册”。新用户必须通过手机号验证码注册/登录,密码登录只面向已经登录后设置过密码的手机号账号。`POST /api/auth/entry` 只接受 `phone + password`,不支持邮箱、用户名或百梦号登录,也不承担自动建号能力。本文原有“密码自动建号”内容仅作为历史背景保留,当前落地以本更新和 [PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md](./PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md) 为准。
>
> 2026-04-28 更新:为开发期本地/测试服联调新增服务端环境变量 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED`,默认 `false`。仅当该变量显式为 `true` 时,`POST /api/auth/entry` 可对未知手机号用本次密码直接创建账号并登录;默认关闭时仍严格保持未知手机号返回 `401` 的生产语义。该开关不得用于生产环境,也不新增任何前端规则说明文案。
@@ -17,7 +17,7 @@
1. `api-server` 对外只暴露 `phone + password` 的最小接口。
2. `module-auth` 只负责已存在手机号账号的密码校验。
3. 密码入口不创建账号,不接收邮箱、用户名或叙世号。
3. 密码入口不创建账号,不接收邮箱、用户名或百梦号。
4. 登录成功后与 JWT、refresh cookie 的衔接方式。
## 1.1 当前冻结结论
@@ -239,7 +239,7 @@
1. 未知手机号密码登录返回 `401`,且不创建账号。
2. 已登录手机号账号设置密码后可用 `phone + password` 登录。
3. 同手机号错误密码返回 `401`
4. 邮箱、用户名或叙世号作为密码登录标识返回 `400`
4. 邮箱、用户名或百梦号作为密码登录标识返回 `400`
5. 登录成功时返回 access token。
6. 登录成功时写回 refresh cookie。
7. `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED` 默认关闭时行为不变。

View File

@@ -19,7 +19,7 @@
沿用现有 `POST /api/auth/entry`
1. 请求字段固定为 `phone``password`,前端只提交手机号。
2. 后端只按标准手机号归一化后查找账号,不兼容邮箱、用户名、叙世号或历史开发游客标识。
2. 后端只按标准手机号归一化后查找账号,不兼容邮箱、用户名、百梦号或历史开发游客标识。
3. 手机号不存在时返回 `401`,不创建账号。
4. 手机号存在但未设置密码时返回 `401`
5. 校验成功后签发 access token并写入 refresh cookie。

View File

@@ -0,0 +1,22 @@
# 网页端首页模块内容同步移动端首页 2026-04-30
## 背景
平台首页移动端已经收口为 `推荐 / 今日游戏 / 游戏分类` 三个频道。网页端首页保留宽屏布局,但模块文案和数据语义仍残留 `趋势关注``最新发布``作品广场` 等旧入口口径,导致双端首页内容不一致。
## 落地规则
1. 网页端首页只调整模块内容和文案,不改变现有宽屏栅格、面板数量与卡片布局。
2. `推荐` 使用移动端推荐频道同源数据:精选作品优先,并与最新公开作品去重合并。
3. `趋势关注` 改为 `今日游戏`,数据只取今天首次发布的公开作品,不把今天更新的旧作品计入今日游戏。
4. `最新发布` 改为 `作品分类`,数据使用当前分类组内按综合指标排序后的作品。
5. 首页首屏和快捷区域不再展示 `作品广场` 文案。
6. 删除首页中的 `公开作品` 兜底模块;快捷区域只在存在最近作品或最近浏览时显示,不再用空模块占位。
## 验收标准
1. 网页端首页仍保持原有 hero、右侧列表、中部双栏与底部网格布局。
2. 网页端可见模块包含推荐、今日游戏、作品分类。
3. 网页端首页不再出现 `趋势关注``最新发布``作品广场`
4. 无最近作品和最近浏览时,网页端首页不再展示 `公开作品` 快捷模块。
5. 今日游戏与移动端 `今日游戏` 频道使用同一发布时间过滤规则。

View File

@@ -0,0 +1,26 @@
# 平台首页作品模糊搜索 2026-05-01
## 背景
首页顶部搜索框原本主要承担公开编号直达能力,适合输入 `SY / CW / BF / M3 / PZ` 编号后打开用户或作品详情。用户在浏览首页时也会按作品名称、作者昵称或作品描述回忆作品,现有搜索口径没有覆盖这些常见路径。
## 落地规则
1. 首页搜索框先在当前公开作品聚合列表中做本地模糊匹配。
2. 匹配字段包含:
- 作品 ID`publicWorkCode``profileId``workId`
- 作品名称:`worldName`
- 作者昵称:`authorDisplayName`
- 作品描述:`summaryText``subtitle`
3. 匹配忽略大小写,允许用户输入去掉空格、连字符或下划线后的连续片段,例如 `PZEPUBLIC1` 命中 `PZ-EPUBLIC1`
4. 输入完整公开作品号并本地命中时,保留既有作品号直达行为。
5. 输入模糊片段命中公开作品时,在首页直接展示搜索结果列表,点击结果打开对应作品详情。
6. 当前公开作品列表无命中时,保留既有公开编号直达兜底,继续支持远端按作品号或百梦号查找。
## 验收标准
1. 输入作品号片段可命中对应公开作品,输入完整公开作品号仍可直达。
2. 输入作品名称片段可命中对应公开作品。
3. 输入作者昵称片段可命中对应公开作品。
4. 输入作品描述片段可命中对应公开作品。
5. 未命中本地公开作品时,原有编号搜索行为不退化。

View File

@@ -0,0 +1,18 @@
# 平台首页移动端底部 Dock 可见视口修复
## 背景
手机浏览器会把顶部地址栏纳入传统 `100vh` 的计算,导致平台首页根容器高于真实可见区域。底部 dock 虽然在 flex 布局末尾,但会被推到浏览器可见区域之外,用户需要滚动或收起地址栏后才能看到。
## 落地口径
- 平台入口根壳统一使用 `.platform-viewport-shell`,优先按 `100dvh` 约束高度,旧浏览器回退到 `100vh`
- 移动端首页底部 dock 使用 `.platform-mobile-bottom-dock` 固定在可见视口底部,并叠加 `safe-area-inset-bottom`
- 移动端首页内容壳通过 `--platform-bottom-dock-outer-height` 预留底部空间,避免滚动内容被固定 dock 遮挡。
- 不新增 UI 说明文案,不改变底部导航业务语义。
## 验收
- 手机竖屏打开平台首页时,底部 dock 始终贴住浏览器可见区域底部。
- 浏览器地址栏展开时dock 不应被挤到屏幕外。
- 主页、分类、创作、存档、我的五个 tab 均保持原有点击行为。

View File

@@ -0,0 +1,24 @@
# 百梦产品命名规范落地说明
## 背景
平台对外中文命名统一使用以下称谓:
- 产品中文展示名:`百梦`
- 平台内消费单位:`光点`
- 公开账号标识:`百梦号`
- 创作侧面向创作者称谓:`百梦主`
## 落地边界
1. 前端页面、弹窗、测试断言和后端返回给用户的中文错误文案统一使用新称谓。
2. SpacetimeDB 表字段、Rust/TypeScript contract 字段、流水来源枚举、`points_*` 商品 ID、`public_user_code` 字段名继续保持不变,避免引入数据库迁移和历史数据兼容风险。
3. 公开编号现有 `SY-XXXXXXXX` 格式本轮不迁移,只调整用户可见标签为“百梦号”;编号格式如需改为新前缀,应另起迁移方案并同步老用户兼容策略。
4. 历史日志、构建产物、第三方依赖和生成绑定不参与本轮文本替换。
## 验收点
1. 首页、登录绑定页、我的页和搜索结果不再展示旧产品名。
2. 钱包、充值、邀请、兑换码、资产计费和拼图道具确认文案统一展示“光点”。
3. 账号公开标识相关错误和搜索空状态统一展示“百梦号”。
4. 创作相关可见默认称谓使用“百梦主”。

View File

@@ -0,0 +1,24 @@
# 百梦产品命名替换落地说明
## 1. 本轮目标
本轮统一平台对外中文命名,当前生效称谓如下:
- 产品中文展示名:`百梦`
- 平台内消费单位:`光点`
- 公开账号标识:`百梦号`
- 创作侧面向创作者称谓:`百梦主`
## 2. 落地范围
1. 前端网页、管理后台、HTML 标题、metadata 与品牌标识统一展示“百梦”。
2. 钱包、充值、邀请、兑换码、资产计费、拼图道具与作者激励统一展示“光点”。
3. 公开账号标识、搜索兜底、登录限制与错误信息统一展示“百梦号”。
4. 创作侧面向创作者的称谓统一展示“百梦主”。
5. 后端错误信息、默认商品文案、测试断言与文档说明同步更新。
## 3. 非目标
1. 本轮只调整对外文本不修改数据库字段名、API 字段名、流水 source、商品 productId 或现有 `SY-XXXXXXXX` 公开编号格式。
2. 不修改 SpacetimeDB 表结构,因此不需要新增 migration。
3. 不引入新的前端页面或后端系统。

Some files were not shown because too many files have changed in this diff Show More