Merge remote-tracking branch 'origin/master'

This commit is contained in:
Hermes Agent
2026-05-04 03:02:49 +08:00
95 changed files with 8093 additions and 683 deletions

View File

@@ -0,0 +1,4 @@
interface:
display_name: "新增玩法入口"
short_description: "把新增玩法入口的文档、配置、路由和验证流程一次收口"
default_prompt: "Use $genarrative-gameplay-entry-type to add a new gameplay entry type end to end in Genarrative."

View File

@@ -44,7 +44,8 @@ npm run dev
补充说明: 补充说明:
- `npm run dev` 会启动 SpacetimeDB standalone、Rust `api-server` Vite 前端,适合完整联调。 - `npm run dev` 会启动 SpacetimeDB standalone、Rust `api-server`、主站 Vite 与后台 Vite适合完整联调。
- 主站默认地址是 `http://127.0.0.1:3000`,后台可从 `http://127.0.0.1:3000/admin/` 进入,也可直连 `http://127.0.0.1:3102`
- 如果只想单独启动前端页面,可使用 `npm run dev:web`,默认代理到本地 Rust `api-server` - 如果只想单独启动前端页面,可使用 `npm run dev:web`,默认代理到本地 Rust `api-server`
构建生产包: 构建生产包:

View File

@@ -2,16 +2,22 @@ import type {
AdminDebugHttpRequest, AdminDebugHttpRequest,
AdminDebugHttpResponse, AdminDebugHttpResponse,
AdminDisableProfileRedeemCodeRequest, AdminDisableProfileRedeemCodeRequest,
AdminDisableProfileTaskConfigRequest,
AdminLoginResponse, AdminLoginResponse,
AdminMeResponse, AdminMeResponse,
AdminOverviewResponse, AdminOverviewResponse,
AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest,
ApiErrorEnvelope, ApiErrorEnvelope,
ApiMeta, ApiMeta,
ApiSuccessEnvelope, ApiSuccessEnvelope,
ProfileInviteCodeAdminListResponse,
ProfileInviteCodeAdminResponse, ProfileInviteCodeAdminResponse,
ProfileRedeemCodeAdminListResponse,
ProfileRedeemCodeAdminResponse, ProfileRedeemCodeAdminResponse,
ProfileTaskConfigAdminListResponse,
ProfileTaskConfigAdminResponse,
} from './adminApiTypes'; } from './adminApiTypes';
const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope'; const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
@@ -129,6 +135,13 @@ export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) {
}); });
} }
export function listProfileRedeemCodes(token: string) {
return request<ProfileRedeemCodeAdminListResponse>(
'/admin/api/profile/redeem-codes',
{token},
);
}
export function upsertProfileRedeemCode( export function upsertProfileRedeemCode(
token: string, token: string,
payload: AdminUpsertProfileRedeemCodeRequest, payload: AdminUpsertProfileRedeemCodeRequest,
@@ -143,6 +156,13 @@ export function upsertProfileRedeemCode(
); );
} }
export function listProfileInviteCodes(token: string) {
return request<ProfileInviteCodeAdminListResponse>(
'/admin/api/profile/invite-codes',
{token},
);
}
export function upsertProfileInviteCode( export function upsertProfileInviteCode(
token: string, token: string,
payload: AdminUpsertProfileInviteCodeRequest, payload: AdminUpsertProfileInviteCodeRequest,
@@ -171,6 +191,38 @@ export function disableProfileRedeemCode(
); );
} }
export function listProfileTaskConfigs(token: string) {
return request<ProfileTaskConfigAdminListResponse>(
'/admin/api/profile/tasks',
{token},
);
}
export function upsertProfileTaskConfig(
token: string,
payload: AdminUpsertProfileTaskConfigRequest,
) {
return request<ProfileTaskConfigAdminResponse>('/admin/api/profile/tasks', {
method: 'POST',
token,
body: payload,
});
}
export function disableProfileTaskConfig(
token: string,
payload: AdminDisableProfileTaskConfigRequest,
) {
return request<ProfileTaskConfigAdminResponse>(
'/admin/api/profile/tasks/disable',
{
method: 'POST',
token,
body: payload,
},
);
}
function normalizeBaseUrl(value: string) { function normalizeBaseUrl(value: string) {
return value.trim().replace(/\/+$/, ''); return value.trim().replace(/\/+$/, '');
} }

View File

@@ -106,6 +106,8 @@ export interface AdminDebugHttpResponse {
} }
export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private'; export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
export type ProfileTaskCycle = 'daily';
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user';
export interface AdminUpsertProfileRedeemCodeRequest { export interface AdminUpsertProfileRedeemCodeRequest {
code: string; code: string;
@@ -126,6 +128,23 @@ export interface AdminDisableProfileRedeemCodeRequest {
code: string; code: string;
} }
export interface AdminUpsertProfileTaskConfigRequest {
taskId: string;
title: string;
description?: string | null;
eventKey: string;
cycle: ProfileTaskCycle;
scopeKind: TrackingScopeKind;
threshold: number;
rewardPoints: number;
enabled: boolean;
sortOrder: number;
}
export interface AdminDisableProfileTaskConfigRequest {
taskId: string;
}
export interface ProfileRedeemCodeAdminResponse { export interface ProfileRedeemCodeAdminResponse {
code: string; code: string;
mode: ProfileRedeemCodeMode; mode: ProfileRedeemCodeMode;
@@ -139,6 +158,10 @@ export interface ProfileRedeemCodeAdminResponse {
updatedAt: string; updatedAt: string;
} }
export interface ProfileRedeemCodeAdminListResponse {
entries: ProfileRedeemCodeAdminResponse[];
}
export interface ProfileInviteCodeAdminResponse { export interface ProfileInviteCodeAdminResponse {
userId: string; userId: string;
inviteCode: string; inviteCode: string;
@@ -146,3 +169,28 @@ export interface ProfileInviteCodeAdminResponse {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface ProfileInviteCodeAdminListResponse {
entries: ProfileInviteCodeAdminResponse[];
}
export interface ProfileTaskConfigAdminResponse {
taskId: string;
title: string;
description: string;
eventKey: string;
cycle: ProfileTaskCycle;
scopeKind: TrackingScopeKind;
threshold: number;
rewardPoints: number;
enabled: boolean;
sortOrder: number;
createdBy: string;
createdAt: string;
updatedBy: string;
updatedAt: string;
}
export interface ProfileTaskConfigAdminListResponse {
entries: ProfileTaskConfigAdminResponse[];
}

View File

@@ -10,6 +10,7 @@ import type {
AdminSessionPayload, AdminSessionPayload,
ProfileInviteCodeAdminResponse, ProfileInviteCodeAdminResponse,
ProfileRedeemCodeAdminResponse, ProfileRedeemCodeAdminResponse,
ProfileTaskConfigAdminResponse,
} from '../api/adminApiTypes'; } from '../api/adminApiTypes';
import { import {
clearStoredAdminToken, clearStoredAdminToken,
@@ -21,6 +22,7 @@ import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
import {AdminLoginPage} from '../pages/AdminLoginPage'; import {AdminLoginPage} from '../pages/AdminLoginPage';
import {AdminOverviewPage} from '../pages/AdminOverviewPage'; import {AdminOverviewPage} from '../pages/AdminOverviewPage';
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage'; import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
import {AdminShell} from './AdminShell'; import {AdminShell} from './AdminShell';
import type {AdminRouteId} from './adminRoutes'; import type {AdminRouteId} from './adminRoutes';
import {resolveAdminRoute, routeHash} from './adminRoutes'; import {resolveAdminRoute, routeHash} from './adminRoutes';
@@ -40,6 +42,8 @@ export function AdminApp() {
useState<ProfileRedeemCodeAdminResponse | null>(null); useState<ProfileRedeemCodeAdminResponse | null>(null);
const [inviteResult, setInviteResult] = const [inviteResult, setInviteResult] =
useState<ProfileInviteCodeAdminResponse | null>(null); useState<ProfileInviteCodeAdminResponse | null>(null);
const [taskConfigResult, setTaskConfigResult] =
useState<ProfileTaskConfigAdminResponse | null>(null);
const clearSession = useCallback((message = '') => { const clearSession = useCallback((message = '') => {
clearStoredAdminToken(); clearStoredAdminToken();
@@ -47,6 +51,7 @@ export function AdminApp() {
setAdmin(null); setAdmin(null);
setRedeemResult(null); setRedeemResult(null);
setInviteResult(null); setInviteResult(null);
setTaskConfigResult(null);
setStatus('guest'); setStatus('guest');
setLoginNotice(message); setLoginNotice(message);
}, []); }, []);
@@ -115,6 +120,7 @@ export function AdminApp() {
setAdmin(response.admin); setAdmin(response.admin);
setRedeemResult(null); setRedeemResult(null);
setInviteResult(null); setInviteResult(null);
setTaskConfigResult(null);
setLoginNotice(''); setLoginNotice('');
setStatus('authenticated'); setStatus('authenticated');
}, []); }, []);
@@ -172,6 +178,14 @@ export function AdminApp() {
onResultChange={setInviteResult} onResultChange={setInviteResult}
/> />
) : null} ) : null}
{routeId === 'tasks' ? (
<AdminTaskConfigPage
result={taskConfigResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setTaskConfigResult}
/>
) : null}
</AdminShell> </AdminShell>
); );
} }

View File

@@ -3,6 +3,7 @@ import {
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
ShieldCheck, ShieldCheck,
ListChecks,
TicketCheck, TicketCheck,
TicketPercent, TicketPercent,
} from 'lucide-react'; } from 'lucide-react';
@@ -25,6 +26,7 @@ const routeIcons = {
debug: Bug, debug: Bug,
redeem: TicketPercent, redeem: TicketPercent,
invite: TicketCheck, invite: TicketCheck,
tasks: ListChecks,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>; } satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({ export function AdminShell({

View File

@@ -1,4 +1,4 @@
export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite'; export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite' | 'tasks';
export interface AdminRouteDefinition { export interface AdminRouteDefinition {
id: AdminRouteId; id: AdminRouteId;
@@ -11,6 +11,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{id: 'debug', label: 'API 调试', hash: '#debug'}, {id: 'debug', label: 'API 调试', hash: '#debug'},
{id: 'redeem', label: '兑换码', hash: '#redeem'}, {id: 'redeem', label: '兑换码', hash: '#redeem'},
{id: 'invite', label: '邀请码', hash: '#invite'}, {id: 'invite', label: '邀请码', hash: '#invite'},
{id: 'tasks', label: '任务配置', hash: '#tasks'},
]; ];
export function resolveAdminRoute(hash: string): AdminRouteId { export function resolveAdminRoute(hash: string): AdminRouteId {

View File

@@ -0,0 +1,45 @@
import type {TrackingScopeKind} from '../api/adminApiTypes';
export interface AdminTrackingEventDefinition {
key: string;
title: string;
scopeKind: TrackingScopeKind;
remark: string;
}
export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
{
key: 'daily_login',
title: '每日登录',
scopeKind: 'user',
remark: '用户打开任务中心时由后端幂等记录,用于每日登录任务进度校验。',
},
];
export function findAdminTrackingEventDefinition(eventKey: string) {
const normalizedEventKey = eventKey.trim();
return (
adminTrackingEventDefinitions.find(
(definition) => definition.key === normalizedEventKey,
) ?? null
);
}
export function filterAdminTrackingEventDefinitions(query: string) {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return adminTrackingEventDefinitions;
}
return adminTrackingEventDefinitions.filter((definition) => {
const haystack = [
definition.key,
definition.title,
definition.scopeKind,
definition.remark,
]
.join(' ')
.toLowerCase();
return haystack.includes(normalizedQuery);
});
}

View File

@@ -1,7 +1,10 @@
import {Save} from 'lucide-react'; import {RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useState} from 'react'; import {FormEvent, useEffect, useState} from 'react';
import {upsertProfileInviteCode} from '../api/adminApiClient'; import {
listProfileInviteCodes,
upsertProfileInviteCode,
} from '../api/adminApiClient';
import type {ProfileInviteCodeAdminResponse} from '../api/adminApiTypes'; import type {ProfileInviteCodeAdminResponse} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils'; import {handlePageError} from './pageUtils';
@@ -21,7 +24,28 @@ export function AdminInviteCodePage({
const [inviteCode, setInviteCode] = useState(''); const [inviteCode, setInviteCode] = useState('');
const [metadataText, setMetadataText] = useState('{}'); const [metadataText, setMetadataText] = useState('{}');
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [listErrorMessage, setListErrorMessage] = useState('');
const [entries, setEntries] = useState<ProfileInviteCodeAdminResponse[]>([]);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
void refreshInviteCodes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshInviteCodes() {
setIsLoading(true);
setListErrorMessage('');
try {
const response = await listProfileInviteCodes(token);
setEntries(response.entries);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) { async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -37,6 +61,8 @@ export function AdminInviteCodePage({
metadata: parseMetadata(metadataText), metadata: parseMetadata(metadataText),
}); });
onResultChange(response); onResultChange(response);
upsertEntry(response);
fillForm(response);
} catch (error: unknown) { } catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage); handlePageError(error, onUnauthorized, setErrorMessage);
} finally { } finally {
@@ -44,6 +70,28 @@ export function AdminInviteCodePage({
} }
} }
function upsertEntry(next: ProfileInviteCodeAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.inviteCode !== next.inviteCode);
return [...rest, next].sort((left, right) => {
const leftUpdatedAt = Date.parse(left.updatedAt);
const rightUpdatedAt = Date.parse(right.updatedAt);
if (Number.isFinite(leftUpdatedAt) && Number.isFinite(rightUpdatedAt)) {
const updatedCompare = rightUpdatedAt - leftUpdatedAt;
if (updatedCompare !== 0) {
return updatedCompare;
}
}
return left.inviteCode.localeCompare(right.inviteCode);
});
});
}
function fillForm(entry: ProfileInviteCodeAdminResponse) {
setInviteCode(entry.inviteCode);
setMetadataText(JSON.stringify(entry.metadata, null, 2));
}
return ( return (
<section className="admin-page"> <section className="admin-page">
<div className="admin-page-heading"> <div className="admin-page-heading">
@@ -51,8 +99,23 @@ export function AdminInviteCodePage({
<h2></h2> <h2></h2>
<p></p> <p></p>
</div> </div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshInviteCodes}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div> </div>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : null}
<div className="admin-two-column admin-two-column-wide"> <div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}> <form className="admin-panel admin-form" onSubmit={handleSave}>
<label className="admin-field"> <label className="admin-field">
@@ -90,42 +153,81 @@ export function AdminInviteCodePage({
</button> </button>
</form> </form>
<section className="admin-panel admin-result-panel"> <div className="admin-stack">
<div className="admin-panel-heading"> <section className="admin-panel">
<h3></h3> <div className="admin-panel-heading">
<span>{result?.inviteCode ?? '-'}</span> <h3></h3>
</div> <span>{entries.length}</span>
{result ? ( </div>
<dl className="admin-info-list"> {entries.length ? (
<div> <div className="admin-table-wrap">
<dt>User ID</dt> <table className="admin-table admin-table-compact">
<dd>{result.userId}</dd> <thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.inviteCode}>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.inviteCode}
</button>
</td>
<td>{entry.createdAt}</td>
<td>{entry.updatedAt}</td>
</tr>
))}
</tbody>
</table>
</div> </div>
<div> ) : (
<dt></dt> <div className="admin-empty-state">
<dd>{result.inviteCode}</dd> {isLoading ? '加载中' : '暂无邀请码'}
</div> </div>
<div> )}
<dt></dt> </section>
<dd>{result.createdAt}</dd>
</div> <section className="admin-panel admin-result-panel">
<div> <div className="admin-panel-heading">
<dt></dt> <h3></h3>
<dd>{result.updatedAt}</dd> <span>{result?.inviteCode ?? '-'}</span>
</div> </div>
<div> {result ? (
<dt>Metadata</dt> <dl className="admin-info-list">
<dd> <div>
<pre className="admin-code-block"> <dt></dt>
{JSON.stringify(result.metadata, null, 2)} <dd>{result.inviteCode}</dd>
</pre> </div>
</dd> <div>
</div> <dt></dt>
</dl> <dd>{result.createdAt}</dd>
) : ( </div>
<div className="admin-empty-state"></div> <div>
)} <dt></dt>
</section> <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>
</div> </div>
</section> </section>
); );

View File

@@ -1,8 +1,9 @@
import {PowerOff, Save} from 'lucide-react'; import {PowerOff, RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useState} from 'react'; import {FormEvent, useEffect, useState} from 'react';
import { import {
disableProfileRedeemCode, disableProfileRedeemCode,
listProfileRedeemCodes,
upsertProfileRedeemCode, upsertProfileRedeemCode,
} from '../api/adminApiClient'; } from '../api/adminApiClient';
import type { import type {
@@ -40,8 +41,29 @@ export function AdminRedeemCodePage({
const [disableCode, setDisableCode] = useState(''); const [disableCode, setDisableCode] = useState('');
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [disableErrorMessage, setDisableErrorMessage] = useState(''); const [disableErrorMessage, setDisableErrorMessage] = useState('');
const [listErrorMessage, setListErrorMessage] = useState('');
const [entries, setEntries] = useState<ProfileRedeemCodeAdminResponse[]>([]);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false); const [isDisabling, setIsDisabling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
void refreshRedeemCodes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshRedeemCodes() {
setIsLoading(true);
setListErrorMessage('');
try {
const response = await listProfileRedeemCodes(token);
setEntries(response.entries);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) { async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -63,6 +85,8 @@ export function AdminRedeemCodePage({
mode === 'private' ? splitLines(allowedPublicUserCodes) : [], mode === 'private' ? splitLines(allowedPublicUserCodes) : [],
}); });
onResultChange(response); onResultChange(response);
upsertEntry(response);
fillForm(response);
} catch (error: unknown) { } catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage); handlePageError(error, onUnauthorized, setErrorMessage);
} finally { } finally {
@@ -83,6 +107,8 @@ export function AdminRedeemCodePage({
code: disableCode.trim(), code: disableCode.trim(),
}); });
onResultChange(response); onResultChange(response);
upsertEntry(response);
fillForm(response);
} catch (error: unknown) { } catch (error: unknown) {
handlePageError(error, onUnauthorized, setDisableErrorMessage); handlePageError(error, onUnauthorized, setDisableErrorMessage);
} finally { } finally {
@@ -90,6 +116,34 @@ export function AdminRedeemCodePage({
} }
} }
function upsertEntry(next: ProfileRedeemCodeAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.code !== next.code);
return [...rest, next].sort((left, right) => {
const leftUpdatedAt = Date.parse(left.updatedAt);
const rightUpdatedAt = Date.parse(right.updatedAt);
if (Number.isFinite(leftUpdatedAt) && Number.isFinite(rightUpdatedAt)) {
const updatedCompare = rightUpdatedAt - leftUpdatedAt;
if (updatedCompare !== 0) {
return updatedCompare;
}
}
return left.code.localeCompare(right.code);
});
});
}
function fillForm(entry: ProfileRedeemCodeAdminResponse) {
setCode(entry.code);
setMode(entry.mode);
setRewardPoints(String(entry.rewardPoints));
setMaxUses(String(entry.maxUses));
setEnabled(entry.enabled);
setAllowedUserIds(entry.allowedUserIds.join('\n'));
setAllowedPublicUserCodes('');
setDisableCode(entry.code);
}
return ( return (
<section className="admin-page"> <section className="admin-page">
<div className="admin-page-heading"> <div className="admin-page-heading">
@@ -97,8 +151,23 @@ export function AdminRedeemCodePage({
<h2></h2> <h2></h2>
<p></p> <p></p>
</div> </div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshRedeemCodes}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div> </div>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : null}
<div className="admin-two-column admin-two-column-wide"> <div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}> <form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-form-row"> <div className="admin-form-row">
@@ -200,6 +269,48 @@ export function AdminRedeemCodePage({
</form> </form>
<div className="admin-stack"> <div className="admin-stack">
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{entries.length}</span>
</div>
{entries.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th>Code</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.code}>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.code}
</button>
<small>{redeemModeLabel(entry.mode)}</small>
</td>
<td>{entry.rewardPoints}</td>
<td>{entry.enabled ? '启用' : '停用'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无兑换码'}
</div>
)}
</section>
<form className="admin-panel admin-form" onSubmit={handleDisable}> <form className="admin-panel admin-form" onSubmit={handleDisable}>
<label className="admin-field"> <label className="admin-field">
<span> Code</span> <span> Code</span>
@@ -273,3 +384,7 @@ function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10); const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
} }
function redeemModeLabel(value: ProfileRedeemCodeMode) {
return redeemModes.find((item) => item.value === value)?.label ?? value;
}

View File

@@ -0,0 +1,545 @@
import {ChevronDown, PowerOff, RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useMemo, useState} from 'react';
import {
disableProfileTaskConfig,
listProfileTaskConfigs,
upsertProfileTaskConfig,
} from '../api/adminApiClient';
import type {
ProfileTaskConfigAdminResponse,
ProfileTaskCycle,
TrackingScopeKind,
} from '../api/adminApiTypes';
import {
filterAdminTrackingEventDefinitions,
findAdminTrackingEventDefinition,
} from '../config/trackingEventDefinitions';
import {handlePageError} from './pageUtils';
interface AdminTaskConfigPageProps {
token: string;
result: ProfileTaskConfigAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileTaskConfigAdminResponse) => void;
}
const taskCycles: Array<{value: ProfileTaskCycle; label: string}> = [
{value: 'daily', label: '每日'},
];
const scopeKinds: Array<{value: TrackingScopeKind; label: string}> = [
{value: 'user', label: '用户'},
{value: 'site', label: '整站'},
{value: 'work', label: '作品'},
{value: 'module', label: '模块'},
];
export function AdminTaskConfigPage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminTaskConfigPageProps) {
const [entries, setEntries] = useState<ProfileTaskConfigAdminResponse[]>([]);
const [taskId, setTaskId] = useState('daily_login');
const [title, setTitle] = useState('每日登录');
const [description, setDescription] = useState('');
const [eventKey, setEventKey] = useState('daily_login');
const [eventKeySearch, setEventKeySearch] = useState('每日登录');
const [isEventKeyPickerOpen, setIsEventKeyPickerOpen] = useState(false);
const [cycle, setCycle] = useState<ProfileTaskCycle>('daily');
const [scopeKind, setScopeKind] = useState<TrackingScopeKind>('user');
const [threshold, setThreshold] = useState('1');
const [rewardPoints, setRewardPoints] = useState('10');
const [sortOrder, setSortOrder] = useState('10');
const [enabled, setEnabled] = useState(true);
const [disableTaskId, setDisableTaskId] = useState('daily_login');
const [errorMessage, setErrorMessage] = useState('');
const [disableErrorMessage, setDisableErrorMessage] = useState('');
const [listErrorMessage, setListErrorMessage] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
void refreshTaskConfigs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const selectedEventDefinition = useMemo(
() => findAdminTrackingEventDefinition(eventKey),
[eventKey],
);
const filteredEventDefinitions = useMemo(
() => filterAdminTrackingEventDefinitions(eventKeySearch),
[eventKeySearch],
);
async function refreshTaskConfigs() {
setIsLoading(true);
setListErrorMessage('');
try {
const response = await listProfileTaskConfigs(token);
setEntries(response.entries);
const dailyLogin = response.entries.find(
(entry) => entry.taskId === 'daily_login',
);
if (dailyLogin) {
fillForm(dailyLogin);
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
setIsSaving(true);
try {
const response = await upsertProfileTaskConfig(token, {
taskId: taskId.trim(),
title: title.trim(),
description,
eventKey: eventKey.trim(),
cycle,
scopeKind,
threshold: parsePositiveInteger(threshold),
rewardPoints: parsePositiveInteger(rewardPoints),
enabled,
sortOrder: parseInteger(sortOrder),
});
onResultChange(response);
upsertEntry(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 disableProfileTaskConfig(token, {
taskId: disableTaskId.trim(),
});
onResultChange(response);
upsertEntry(response);
fillForm(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setDisableErrorMessage);
} finally {
setIsDisabling(false);
}
}
function upsertEntry(next: ProfileTaskConfigAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.taskId !== next.taskId);
return [...rest, next].sort((left, right) => {
if (left.sortOrder !== right.sortOrder) {
return left.sortOrder - right.sortOrder;
}
return left.taskId.localeCompare(right.taskId);
});
});
}
function fillForm(entry: ProfileTaskConfigAdminResponse) {
setTaskId(entry.taskId);
setTitle(entry.title);
setDescription(entry.description);
setEventKey(entry.eventKey);
setCycle(entry.cycle);
setScopeKind(entry.scopeKind);
setThreshold(String(entry.threshold));
setRewardPoints(String(entry.rewardPoints));
setSortOrder(String(entry.sortOrder));
setEnabled(entry.enabled);
setDisableTaskId(entry.taskId);
const nextDefinition = findAdminTrackingEventDefinition(entry.eventKey);
setEventKeySearch(nextDefinition?.title ?? entry.eventKey);
setIsEventKeyPickerOpen(false);
}
function selectEventKey(nextEventKey: string) {
const nextDefinition = findAdminTrackingEventDefinition(nextEventKey);
setEventKey(nextEventKey);
if (nextDefinition) {
setEventKeySearch(nextDefinition.title);
setScopeKind(nextDefinition.scopeKind);
} else {
setEventKeySearch(nextEventKey);
}
setIsEventKeyPickerOpen(false);
}
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={refreshTaskConfigs}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : null}
<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>Task ID</span>
<input
value={taskId}
onChange={(event) => setTaskId(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-form-row">
<label className="admin-field">
<span></span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label className="admin-field">
<span>Event Key</span>
<div
className="admin-combobox"
onBlur={(event) => {
const nextTarget = event.relatedTarget;
if (
!(nextTarget instanceof Node) ||
!event.currentTarget.contains(nextTarget)
) {
setIsEventKeyPickerOpen(false);
}
}}
>
<div className="admin-combobox-control">
<input
aria-label="Event Key"
value={eventKeySearch || eventKey}
onChange={(event) => {
const nextValue = event.target.value;
setEventKeySearch(nextValue);
setIsEventKeyPickerOpen(true);
}}
onFocus={() => setIsEventKeyPickerOpen(true)}
/>
<button
aria-label="展开 Event Key"
className="admin-combobox-toggle"
type="button"
onClick={() =>
setIsEventKeyPickerOpen((current) => !current)
}
>
<ChevronDown size={16} aria-hidden="true" />
</button>
</div>
{isEventKeyPickerOpen ? (
<div className="admin-combobox-menu" role="listbox">
{filteredEventDefinitions.length ? (
filteredEventDefinitions.map((definition) => (
<button
key={definition.key}
className="admin-combobox-option"
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => selectEventKey(definition.key)}
>
<strong>{definition.title}</strong>
<span>{definition.key}</span>
<small>{definition.remark}</small>
</button>
))
) : (
<div className="admin-combobox-empty">
{eventKeySearch.trim() ? (
<button
className="admin-text-button"
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() =>
selectEventKey(eventKeySearch.trim())
}
>
使 key
</button>
) : null}
</div>
)}
</div>
) : null}
</div>
{selectedEventDefinition ? (
<small className="admin-field-note">
{selectedEventDefinition.remark}
</small>
) : (
<small className="admin-field-note">
key
</small>
)}
</label>
</div>
<label className="admin-field">
<span></span>
<textarea
rows={3}
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</label>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<select
value={cycle}
onChange={(event) =>
setCycle(event.target.value as ProfileTaskCycle)
}
>
{taskCycles.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="admin-field">
<span></span>
<select
value={scopeKind}
onChange={(event) =>
setScopeKind(event.target.value as TrackingScopeKind)
}
>
{scopeKinds.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={threshold}
onChange={(event) => setThreshold(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={rewardPoints}
onChange={(event) => setRewardPoints(event.target.value)}
/>
</label>
</div>
<label className="admin-field admin-field-compact">
<span></span>
<input
step={1}
type="number"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={
isSaving ||
!taskId.trim() ||
!title.trim() ||
!eventKey.trim() ||
!parsePositiveInteger(threshold) ||
!parsePositiveInteger(rewardPoints)
}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
<div className="admin-stack">
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{entries.length}</span>
</div>
{entries.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.taskId}>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.title || entry.taskId}
</button>
<small>{entry.taskId}</small>
</td>
<td>{entry.rewardPoints}</td>
<td>{entry.enabled ? '启用' : '停用'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无配置'}
</div>
)}
</section>
<form className="admin-panel admin-form" onSubmit={handleDisable}>
<label className="admin-field">
<span> Task ID</span>
<input
value={disableTaskId}
onChange={(event) => setDisableTaskId(event.target.value)}
/>
</label>
{disableErrorMessage ? (
<div className="admin-alert" role="status">
{disableErrorMessage}
</div>
) : null}
<button
className="admin-danger-button"
disabled={isDisabling || !disableTaskId.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?.taskId ?? '-'}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt>Task ID</dt>
<dd>{result.taskId}</dd>
</div>
<div>
<dt>Event Key</dt>
<dd>{result.eventKey}</dd>
</div>
<div>
<dt></dt>
<dd>{result.rewardPoints}</dd>
</div>
<div>
<dt></dt>
<dd>{result.threshold}</dd>
</div>
<div>
<dt></dt>
<dd>{result.enabled ? '启用' : '停用'}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedBy}</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;
}
function parseInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}

View File

@@ -321,6 +321,13 @@ button:disabled {
outline: none; outline: none;
} }
.admin-field-note {
color: #667682;
font-size: 12px;
font-weight: 500;
line-height: 1.45;
}
.admin-field textarea { .admin-field textarea {
min-height: 112px; min-height: 112px;
resize: vertical; resize: vertical;
@@ -333,6 +340,96 @@ button:disabled {
box-shadow: 0 0 0 3px rgba(18, 110, 130, 0.16); box-shadow: 0 0 0 3px rgba(18, 110, 130, 0.16);
} }
.admin-combobox {
position: relative;
min-width: 0;
}
.admin-combobox-control {
display: grid;
grid-template-columns: minmax(0, 1fr) 38px;
}
.admin-combobox-control input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.admin-combobox-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
border: 1px solid #cbd8e0;
border-left: 0;
border-radius: 0 8px 8px 0;
color: #52616d;
background: #fbfdfe;
}
.admin-combobox:focus-within .admin-combobox-toggle {
border-color: #126e82;
box-shadow: 0 0 0 3px rgba(18, 110, 130, 0.16);
}
.admin-combobox-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
left: 0;
z-index: 30;
display: grid;
max-height: 260px;
overflow: auto;
border: 1px solid #cbd8e0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 16px 40px rgba(23, 33, 43, 0.14);
padding: 6px;
}
.admin-combobox-option {
display: grid;
gap: 3px;
width: 100%;
border: 0;
border-radius: 7px;
color: #17212b;
background: transparent;
padding: 9px 10px;
text-align: left;
}
.admin-combobox-option:hover,
.admin-combobox-option:focus-visible {
background: #e7f3f5;
outline: none;
}
.admin-combobox-option span {
color: #0f5666;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
font-weight: 700;
}
.admin-combobox-option small,
.admin-combobox-empty {
color: #667682;
font-size: 12px;
font-weight: 500;
line-height: 1.45;
}
.admin-combobox-empty {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px;
}
.admin-switch-field { .admin-switch-field {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -384,6 +481,16 @@ button:disabled {
background: #eef3f6; background: #eef3f6;
} }
.admin-text-button {
display: inline;
border: 0;
color: #0f5666;
background: transparent;
padding: 0;
text-align: left;
font-weight: 700;
}
.admin-alert { .admin-alert {
border: 1px solid #efc0bd; border: 1px solid #efc0bd;
border-radius: 8px; border-radius: 8px;
@@ -443,6 +550,17 @@ button:disabled {
font-size: 12px; font-size: 12px;
} }
.admin-table td small {
display: block;
margin-top: 3px;
color: #667682;
font-size: 12px;
}
.admin-table-compact {
min-width: 360px;
}
.admin-status { .admin-status {
display: inline-flex; display: inline-flex;
max-width: 460px; max-width: 460px;
@@ -608,7 +726,7 @@ button:disabled {
left: 0; left: 0;
z-index: 20; z-index: 20;
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
border-top: 1px solid #d8e2e8; border-top: 1px solid #d8e2e8;
background: rgba(255, 255, 255, 0.94); background: rgba(255, 255, 255, 0.94);
padding: 8px 10px calc(8px + env(safe-area-inset-bottom)); padding: 8px 10px calc(8px + env(safe-area-inset-bottom));

View File

@@ -15,10 +15,12 @@ export default defineConfig(({mode}) => {
env.ADMIN_API_TARGET || env.ADMIN_API_TARGET ||
env.GENARRATIVE_API_TARGET || env.GENARRATIVE_API_TARGET ||
`http://127.0.0.1:${env.GENARRATIVE_API_PORT || '3100'}`; `http://127.0.0.1:${env.GENARRATIVE_API_PORT || '3100'}`;
const base = env.ADMIN_WEB_BASE || '/admin/';
return { return {
root: adminWebRoot, root: adminWebRoot,
envDir: repoRoot, envDir: repoRoot,
base,
plugins: [react()], plugins: [react()],
server: { server: {
proxy: { proxy: {

View File

@@ -0,0 +1,24 @@
[Unit]
Description=Jenkins inbound agent %i
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=0
[Service]
Type=simple
User=root
Group=root
EnvironmentFile=/etc/jenkins-agent/%i.env
WorkingDirectory=/var/lib/jenkins/agent/%i
ExecStart=/usr/local/bin/jenkins-inbound-agent-start %i
Restart=always
RestartSec=10
KillSignal=SIGINT
TimeoutStopSec=30
# 当前生产流水线仍包含服务器初始化、systemd 与 Nginx 写入等特权操作。
# 后续若将 agent 降权到 jenkins 用户,需要先把流水线命令收敛到精确 sudo 白名单。
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View File

@@ -11,6 +11,8 @@
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。 - [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。
- [参考目录](./reference/README.md):脚本/Function 速查入口。 - [参考目录](./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)。 重点补充RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
- [埋点查询](./tracking/README.md):埋点原始事件与聚合投影的本地 SQL 查询。
- [运营查询](./operations/README.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)。 - [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)。
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [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)。 生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [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)。
@@ -33,3 +35,5 @@ SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段
- `technical/`:偏技术选型、实现路线、竞品/产品形态拆解。 - `technical/`:偏技术选型、实现路线、竞品/产品形态拆解。
- `planning/`:偏阶段优先级与推进顺序。 - `planning/`:偏阶段优先级与推进顺序。
- `reference/`:偏目录、速查、检索辅助。 - `reference/`:偏目录、速查、检索辅助。
- `tracking/`:偏埋点原始事实和聚合投影查询,不放任务进度或钱包对账。
- `operations/`:偏后台运营核查、对账和排障查询。

View File

@@ -0,0 +1,33 @@
# 个人任务运营查询手册
更新时间:`2026-05-03`
## 任务配置
```powershell
spacetime sql <db> "SELECT * FROM profile_task_config ORDER BY updated_at DESC"
spacetime sql <db> "SELECT * FROM profile_task_config WHERE task_id = 'daily_login'"
```
## 任务进度
```powershell
spacetime sql <db> "SELECT * FROM profile_task_progress WHERE user_id = '<user_id>' ORDER BY updated_at DESC"
spacetime sql <db> "SELECT * FROM profile_task_progress WHERE user_id = '<user_id>' AND task_id = 'daily_login' ORDER BY day_key DESC"
```
## 领奖记录
```powershell
spacetime sql <db> "SELECT * FROM profile_task_reward_claim WHERE user_id = '<user_id>' ORDER BY claimed_at DESC"
spacetime sql <db> "SELECT * FROM profile_task_reward_claim WHERE claim_id = '<user_id>:daily_login:<day_key>'"
```
## 钱包流水对账
```powershell
spacetime sql <db> "SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>' AND source_type = 'DailyTaskReward' ORDER BY created_at DESC"
spacetime sql <db> "SELECT * FROM profile_dashboard_state WHERE user_id = '<user_id>'"
```
每日登录奖励流水 ID 为 `task-reward:<user_id>:daily_login:<day_key>`,领奖记录中的 `wallet_ledger_id` 必须能在 `profile_wallet_ledger` 中查到。

View File

@@ -0,0 +1,5 @@
# 运营查询
本目录存放后台运营核查、对账和排障查询,不承载埋点系统本身的查询手册。
- [PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md](./PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md):个人任务配置、进度、领奖记录与钱包流水对账查询。

View File

@@ -151,9 +151,9 @@ Agent 的职责是帮助用户确认可以直接编译 demo 的最小配置:
题材决定后续生成或选择物品素材的方向。用户可以自定义主题,例如水果、玩具、食物、符号等。 题材决定后续生成或选择物品素材的方向。用户可以自定义主题,例如水果、玩具、食物、符号等。
首版 demo 不接入真实图片生成。运行态可消除物统一使用纯色几何体表现,不使用透明气泡,也不在图案上放文字标识。题材仍决定后端生成的 `visualKey` 和尺寸比例,但前端首版用差异化颜色与几何造型表现可消除物,例如圆形、三角形、菱形、五角星、梯形、平行四边形等,避免玩家在堆叠状态下难以辨认。 首版 demo 不接入真实图片生成。当前运行态可消除物统一使用参考图方向的 25 个积木件类型表现,不使用透明气泡,也不在图案上放文字标识。前端首版用差异化颜色、积木造型和 3D 程序化模型表现可消除物,避免玩家在堆叠状态下难以辨认。
水果图形资产需要具备常识可感知的相对大小关系,但不要求真实比例绝对精准。首版固定规则为:西瓜明显大于苹果;苹果、橙子、梨、桃子为中等尺寸;葡萄、李子、青柠等小型水果略小。该尺寸由后端运行态物品 `radius` 下发,前端只按快照表现。 可消除物尺寸使用五档相对体积规则XL 型相对体积为 `1.60~2.30`L 型为 `1.25~1.60`M 型为 `1.00`XS 型为 `0.65~0.85`S 型为 `0.35~0.50`。单局中 XL / L / M / XS / S 按本局使用的消除物类型数的 `20% / 30% / 30% / 15% / 5%` 分配;非整数配额按最大余数补齐,确保总数等于本局使用类型数量。同一关卡内同一个颜色和造型的物品只能对应一个尺寸档位;可存在同尺寸但不同颜色和造型的物品。后端运行态通过 `radius` 下发权威尺寸,前端只按快照表现。
### 需要消除次数 ### 需要消除次数
@@ -265,6 +265,16 @@ totalItemCount = clearCount * 3
每种物品数量必须是 `3` 的倍数,避免生成无法通关的局。 每种物品数量必须是 `3` 的倍数,避免生成无法通关的局。
生成的消除物类型数由用户填写的需要消除次数决定:
```text
itemTypeCount = clearCount <= 25 ? clearCount : 25
```
`clearCount <= 25` 时,本局生成的 `itemTypeId` 数量等于 `clearCount`,每种类型默认生成 `3` 件;当 `clearCount > 25` 时,本局最多生成 `25``itemTypeId`,后续消除组按这 `25` 种类型轮转补齐,且每种类型最终数量仍必须保持 `3` 的倍数。
同一局内这些类型必须分别使用不同的形状和颜色组合,不能出现两个组看起来像同一种物体的情况。
## 8.4 阶段陆续生成 ## 8.4 阶段陆续生成
每局物品允许阶段陆续生成。 每局物品允许阶段陆续生成。
@@ -277,8 +287,8 @@ totalItemCount = clearCount * 3
首版 demo 使用 2D 图案素材。 首版 demo 使用 2D 图案素材。
1. demo 至少提供 `10` 种颜色与几何造型组合素材。 1. demo 至少提供 `25`彼此不同的颜色与几何造型组合素材,支撑 `clearCount > 25` 时的类型上限
2.题材为水果时,后端仍可切换到 `10` 种水果视觉键和尺寸比例,但前端首版必须把这些视觉键映射为无文字的纯色几何体,不能显示为水果图、透明气泡或文字标记。 2.前 demo 使用 25 个积木件视觉键作为默认素材池;前端首版必须把这些视觉键映射为无文字的纯色 2D 图标和程序化 3D 积木模型,不能显示为透明气泡或文字标记。
3. 后续可以尝试替换为伪 3D 或 3D 模型。 3. 后续可以尝试替换为伪 3D 或 3D 模型。
4. 用户题材主题后续会映射为符合常识预期的物品集合。 4. 用户题材主题后续会映射为符合常识预期的物品集合。
@@ -310,6 +320,8 @@ totalItemCount = clearCount * 3
飞行动画过程中,物品不再与其他物品产生碰撞。 飞行动画过程中,物品不再与其他物品产生碰撞。
当前 3D 实验模式下,物品进入备选栏后必须从圆形空间的物理世界移除;备选栏只展示该物品同款 3D 模型的独立预览,固定为斜 `45` 度便于识别,不再参与场内碰撞、重力或堆叠。
前端播放即时反馈的同时,必须向后端提交点击意图。后端确认后固化入槽结果;后端拒绝时,前端恢复物品位置和备选栏表现。 前端播放即时反馈的同时,必须向后端提交点击意图。后端确认后固化入槽结果;后端拒绝时,前端恢复物品位置和备选栏表现。
## 8.9 备选栏 ## 8.9 备选栏
@@ -318,8 +330,9 @@ totalItemCount = clearCount * 3
1. 每次点击进入即时反馈流程后,前端先把物品表现为进入备选栏。 1. 每次点击进入即时反馈流程后,前端先把物品表现为进入备选栏。
2. 备选栏中每出现 `3` 个相同物品 id前端立即播放自动消除效果并腾出格子。 2. 备选栏中每出现 `3` 个相同物品 id前端立即播放自动消除效果并腾出格子。
3. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正 3. 3D 模式下,备选栏格子展示从场内取出的同款 3D 模型预览,视角固定斜 `45` 度,不使用另一套不一致的 UI 图标;托盘预览必须共享一个 WebGL renderer不能因多个预览上下文导致中心场地模型不可见WebGL 回退或 `2D` 模式下才使用保留的 2D 图标
4. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准 4. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正
5. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准。
## 8.10 胜利 ## 8.10 胜利

View File

@@ -22,15 +22,16 @@
-`2026-04-19` 起,“最近游玩 / 历史浏览”已从“我的”页迁出,改为平台一级主 Tab“存档”。 -`2026-04-19` 起,“最近游玩 / 历史浏览”已从“我的”页迁出,改为平台一级主 Tab“存档”。
- 对应母文档见 [PLATFORM_SAVE_TAB_PRD_2026-04-19.md](/E:/Repos/Genarrative/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md)。 - 对应母文档见 [PLATFORM_SAVE_TAB_PRD_2026-04-19.md](/E:/Repos/Genarrative/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md)。
当前“我的”页保留以下 `7` 个独立功能: 当前“我的”页保留以下 `8` 个独立功能:
1. 账号资料与身份卡 1. 账号资料与身份卡
2. 会员中心与充值 2. 会员中心与充值
3. 我的数据看板 3. 我的数据看板
4. 邀请好友 4. 邀请好友
5. 填邀请码 5. 填邀请码
6. 玩家社区 6. 每日任务
7. 设置与账号安全 7. 玩家社区
8. 设置与账号安全
--- ---
@@ -42,8 +43,9 @@
4. [PLATFORM_SAVE_TAB_PRD_2026-04-19.md](/E:/Repos/Genarrative/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md) 4. [PLATFORM_SAVE_TAB_PRD_2026-04-19.md](/E:/Repos/Genarrative/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md)
5. [MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md) 5. [MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md)
6. [MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md) 6. [MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md)
7. [MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md) 7. [PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md](../technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md)
8. [MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md) 8. [MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md)
9. [MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md)
--- ---
@@ -58,7 +60,8 @@
5. 会员中心与充值 5. 会员中心与充值
6. 邀请好友 6. 邀请好友
7. 填邀请码 7. 填邀请码
8. 玩家社区 8. 每日任务
9. 玩家社区
原因: 原因:
@@ -66,6 +69,7 @@
- `3 + 4` 直接增强账号资产与回流体验,短期收益高 - `3 + 4` 直接增强账号资产与回流体验,短期收益高
- `5 + 6` 涉及商业化和关系绑定,依赖结算与奖励台账 - `5 + 6` 涉及商业化和关系绑定,依赖结算与奖励台账
- `7` 最适合放在平台内容层能力稳定后再做 - `7` 最适合放在平台内容层能力稳定后再做
- `8` 依赖埋点聚合、任务配置和钱包流水,首版只接每日登录
--- ---
@@ -76,6 +80,7 @@
- `PlatformHomeView` 继续作为“我的”Tab 首屏承载层 - `PlatformHomeView` 继续作为“我的”Tab 首屏承载层
- 优先采用现有面板、抽屉、弹窗,不新建独立大系统 - 优先采用现有面板、抽屉、弹窗,不新建独立大系统
- 页面只展示后端返回的状态,不自行计算结论型业务状态 - 页面只展示后端返回的状态,不自行计算结论型业务状态
- 每日任务入口放在“常用功能”,点击后弹出独立任务面板
### 4.2 后端边界 ### 4.2 后端边界

View File

@@ -96,8 +96,10 @@ export interface ApiErrorEnvelope {
| 当前管理员 | `GET /admin/api/me` | 管理员 Bearer | | 当前管理员 | `GET /admin/api/me` | 管理员 Bearer |
| 服务与数据库概览 | `GET /admin/api/overview` | 管理员 Bearer | | 服务与数据库概览 | `GET /admin/api/overview` | 管理员 Bearer |
| 受控 HTTP 调试 | `POST /admin/api/debug/http` | 管理员 Bearer | | 受控 HTTP 调试 | `POST /admin/api/debug/http` | 管理员 Bearer |
| 读取兑换码列表 | `GET /admin/api/profile/redeem-codes` | 管理员 Bearer |
| 创建/更新兑换码 | `POST /admin/api/profile/redeem-codes` | 管理员 Bearer | | 创建/更新兑换码 | `POST /admin/api/profile/redeem-codes` | 管理员 Bearer |
| 停用兑换码 | `POST /admin/api/profile/redeem-codes/disable` | 管理员 Bearer | | 停用兑换码 | `POST /admin/api/profile/redeem-codes/disable` | 管理员 Bearer |
| 读取后台邀请码列表 | `GET /admin/api/profile/invite-codes` | 管理员 Bearer |
| 创建/更新注册邀请码 | `POST /admin/api/profile/invite-codes` | 管理员 Bearer | | 创建/更新注册邀请码 | `POST /admin/api/profile/invite-codes` | 管理员 Bearer |
### 4.3 前端类型命名 ### 4.3 前端类型命名
@@ -206,6 +208,10 @@ export interface ProfileRedeemCodeAdminResponse {
updatedAt: string; updatedAt: string;
} }
export interface ProfileRedeemCodeAdminListResponse {
entries: ProfileRedeemCodeAdminResponse[];
}
export interface ProfileInviteCodeAdminResponse { export interface ProfileInviteCodeAdminResponse {
userId: string; userId: string;
inviteCode: string; inviteCode: string;
@@ -213,6 +219,10 @@ export interface ProfileInviteCodeAdminResponse {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface ProfileInviteCodeAdminListResponse {
entries: ProfileInviteCodeAdminResponse[];
}
``` ```
### 4.4 登录 contract ### 4.4 登录 contract
@@ -284,6 +294,31 @@ export interface ProfileInviteCodeAdminResponse {
### 4.7 兑换码管理 contract ### 4.7 兑换码管理 contract
列表请求:
`GET /admin/api/profile/redeem-codes`
成功返回:
```json
{
"entries": [
{
"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"
}
]
}
```
创建/更新请求: 创建/更新请求:
```json ```json
@@ -300,8 +335,6 @@ export interface ProfileInviteCodeAdminResponse {
停用请求: 停用请求:
兑换码管理页的最近一次接口返回记录由 `AdminApp` 维护为管理端会话态,并传入 `AdminRedeemCodePage` 渲染。页面页签通过 hash 切换时子页面会卸载,不能把最近记录只放在兑换码页面内部 `useState` 中,否则切换到其他页签再返回会展示“暂无记录”。该会话态只用于保留当前操作结果,不作为兑换码历史列表;退出登录或重新登录时清空。
```json ```json
{ {
"code": "WELCOME2026" "code": "WELCOME2026"
@@ -325,10 +358,36 @@ export interface ProfileInviteCodeAdminResponse {
} }
``` ```
兑换码管理页进入时必须通过 `GET /admin/api/profile/redeem-codes` 加载数据库已有记录。最近一次接口返回记录仍由 `AdminApp` 维护为管理端会话态,用于展示当前操作结果;历史列表不得依赖该会话态,刷新页面后必须从后端列表接口恢复。列表项点击后回填表单,继续通过同一个 `POST /admin/api/profile/redeem-codes` 修改原记录。
前端只做基础输入约束,最终标准化、私有码用户解析、次数和奖励合法性以 `server-rs` 为准。 前端只做基础输入约束,最终标准化、私有码用户解析、次数和奖励合法性以 `server-rs` 为准。
### 4.8 邀请码管理 contract ### 4.8 邀请码管理 contract
列表请求:
`GET /admin/api/profile/invite-codes`
成功返回:
```json
{
"entries": [
{
"userId": "admin:root:SPRING2026",
"inviteCode": "SPRING2026",
"metadata": {
"batch": "spring"
},
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
}
]
}
```
后台邀请码列表只返回后台运营预置码。后端按 `profile_invite_code.user_id``admin:` 前缀过滤,普通用户在邀请中心生成的个人邀请码不得展示在后台列表中。
创建/更新请求: 创建/更新请求:
```json ```json
@@ -372,19 +431,22 @@ export interface ProfileInviteCodeAdminResponse {
3. 总览页加载失败时展示后端错误,不吞掉 `fetchErrors` 3. 总览页加载失败时展示后端错误,不吞掉 `fetchErrors`
4. API 调试页的 headers 使用键值行编辑,提交前转为 `[{ name, value }]` 4. API 调试页的 headers 使用键值行编辑,提交前转为 `[{ name, value }]`
5. 兑换码页的 `mode=private` 时展示允许用户输入区;其他模式提交空数组。 5. 兑换码页的 `mode=private` 时展示允许用户输入区;其他模式提交空数组。
6. 邀请码页只提交 `inviteCode` 与 JSON 对象 metadata不在前端复制后端邀请码规则 6. 兑换码页和邀请码页进入时加载数据库列表,保存后合并返回记录,点击列表项回填表单进入编辑态
7. 所有按钮的 loading 状态必须锁定重复提交 7. 邀请码页只提交 `inviteCode` 与 JSON 对象 metadata不在前端复制后端邀请码规则
8. 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动 8. 所有按钮的 loading 状态必须锁定重复提交
9. 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动。
## 7. 部署与联调 ## 7. 部署与联调
### 7.1 本地联调 ### 7.1 本地联调
1. 启动后端:`npm run api-server` 1. 完整本地栈直接在仓库根目录执行 `npm run dev`
2. 启动后台前端:在 `apps/admin-web` 执行 `npm run dev` 2. `npm run dev` 默认启动 SpacetimeDB standalone、Rust `api-server`、主站 Vite 和后台 Vite
3. 后台 dev server 通过 Vite proxy 转发 `/admin/api``ADMIN_API_TARGET`;未配置时默认 `http://127.0.0.1:3100` 3. 主站默认地址为 `http://127.0.0.1:3000`,后台可从主站 `http://127.0.0.1:3000/admin/` 进入,也可直连 `http://127.0.0.1:3102`
4. 若使用非 3100 端口,在仓库根目录 `.env.local` 设置 `ADMIN_API_TARGET=http://127.0.0.1:<api-server-port>`,并重启后台前端 dev server 4. 主站 Vite 会把 `/admin/` 转发到后台 dev server贴近生产同域 `/admin/` 入口
5. `GENARRATIVE_API_PORT` 控制 Rust `api-server` 监听端口;`ADMIN_API_TARGET` 只控制后台前端 dev proxy 目标,二者需要指向同一个端口 5. 后台 dev server 通过 Vite proxy 转发 `/admin/api` 到当前 Rust API 地址;`--api-port` 改动时脚本会同步注入 `ADMIN_API_TARGET`
6. 如需单独启动后台前端,可继续执行根脚本 `npm run admin-web:dev`,或在 `apps/admin-web` 执行 `npm run dev`;单独启动时未配置 `ADMIN_API_TARGET` 会默认代理到 `http://127.0.0.1:3100`
7. 后台 dev 端口可用 `npm run dev -- --admin-web-port <port>` 覆盖。
### 7.2 构建部署 ### 7.2 构建部署
@@ -430,8 +492,8 @@ export interface ProfileInviteCodeAdminResponse {
- token 恢复、过期清理、退出登录。 - token 恢复、过期清理、退出登录。
- 总览页正常数据、部分表统计失败、整体请求失败。 - 总览页正常数据、部分表统计失败、整体请求失败。
- API 调试成功访问 `/healthz`,绝对 URL 被后端拒绝。 - API 调试成功访问 `/healthz`,绝对 URL 被后端拒绝。
- 兑换码 public/unique/private 表单提交和停用。 - 兑换码数据库列表加载、列表点击回填、public/unique/private 表单提交和停用。
- 邀请码表单提交、metadata JSON 对象校验和结果展示。 - 邀请码数据库列表加载、普通用户邀请码不展示、列表点击回填、metadata JSON 对象校验和结果展示。
2. 根工程: 2. 根工程:
- `npm run check:encoding` - `npm run check:encoding`
- 后续接入根 workspace 后,补充后台工程 build/typecheck 脚本。 - 后续接入根 workspace 后,补充后台工程 build/typecheck 脚本。

View File

@@ -518,15 +518,25 @@ totalItemCount = clearCount * 3
每种 `itemTypeId` 的数量必须是 `3` 的倍数。 每种 `itemTypeId` 的数量必须是 `3` 的倍数。
消除物类型数按创作输入的 `clearCount` 计算:
```text
itemTypeCount = clearCount <= 25 ? clearCount : 25
```
`clearCount <= 25` 时,运行态快照中的不同 `itemTypeId` 数量必须等于 `clearCount`;当 `clearCount > 25` 时,不同 `itemTypeId` 数量必须等于 `25`。超过 `25` 组的消除目标按这 `25` 种类型轮转生成,确保每种类型的最终数量仍是 `3` 的倍数。
`25` 组在同一局内还必须对应 25 套不同的形状和颜色签名,不能有两组视觉上撞型。
## 9.3 demo 视觉素材 ## 9.3 demo 视觉素材
首版使用内置视觉键和前端内置几何图形资产,不接真实图片生成。 首版使用 25 个内置积木件视觉键和前端内置几何图形资产,不接真实图片生成。
1. 水果题材必须使用 `watermelon-green / apple-red / banana-yellow / grape-purple / melon-green / berry-blue / peach-pink / plum-indigo / lime-lime / orange-orange` 这组内置水果视觉键;前端首版将其映射为纯色几何体,不渲染水果写实图,也不能显示为带文字或透明气泡的小球。 1. 当前 demo 使用 25 个积木件视觉键作为默认素材池;前端首版将其映射为无文字的 2D 图标和程序化 3D 积木模型,不渲染写实图,也不能显示为带文字或透明气泡的小球。
2. 非水果题材暂使用 `red_circle / yellow_triangle / purple_diamond / green_square / blue_star / orange_hexagon / cyan_capsule / pink_heart / lime_leaf / white_moon` 这组兜底颜色形状视觉键 2. `visualKey` 不允许在前端统一兜底为同一个素材;未知 key 至少要有稳定的颜色差异,避免多个不同 `itemTypeId` 被玩家误认为同一种物品
3. `visualKey` 不允许在前端统一兜底为同一个素材;未知 key 至少要有稳定的颜色差异,避免多个不同 `itemTypeId` 被玩家误认为同一种物品 3. 运行态图案必须使用实心、高饱和、无文字的几何 SVG并保持与 3D 模型同一批 `visualKey` 对应关系;外层命中按钮不得再显示半透明气泡底
4. 运行态图案必须使用实心、高饱和、无文字的几何 SVG至少覆盖圆形、三角形、菱形、方形、五角星、六边形、胶囊、心形、梯形、平行四边形等多种轮廓外层命中按钮不得再显示半透明气泡底 4. 每局按使用类型数量分配五档相对体积XL 型 `1.60~2.30``20%`L 型 `1.25~1.60``30%`M 型固定 `1.00``30%`XS 型 `0.65~0.85``15%`S 型 `0.35~0.50``5%`。非整数配额按最大余数补齐,总数必须等于本局使用类型数量
5. 水果题材的相对尺寸由后端权威半径决定,首版要求西瓜明显大于苹果,苹果、橙子、桃子等中型水果大于葡萄、李子、青柠等小型水果;前端不得自行改写规则半径,只负责按快照表现。 5. 同一局内同一个颜色和造型的 `visualKey` 只能对应一个尺寸档位和一个半径,不能出现同一物品类型三件副本大小不同,也不能出现同一视觉键在复用时被分配到两种大小。前端不得自行改写规则半径,只负责按快照表现。
6. 后续接入真实题材图片素材前,必须另补资产生成方案。 6. 后续接入真实题材图片素材前,必须另补资产生成方案。
## 9.4 难度 ## 9.4 难度
@@ -646,9 +656,10 @@ src/components/match3d-runtime/
1. 圆形空间占据主要区域。 1. 圆形空间占据主要区域。
2. 备选栏固定 `7` 格。 2. 备选栏固定 `7` 格。
3. 倒计时清晰但不遮挡物品 3. 3D 模式下,备选栏格子使用与圆形空间内一致的程序化 3D 模型预览,固定斜 `45` 度视角,且不接入场内物理碰撞;托盘预览必须共享一个 WebGL renderer不能每格创建独立 renderer仅 WebGL 回退或 `2D` 模式使用 2D 图标
4. 物品点击区域稳定,不因动画造成布局跳动 4. 倒计时清晰但不遮挡物品。
5. 胜利/失败结算使用独立面板,不在当前面板下方展开 5. 物品点击区域稳定,不因动画造成布局跳动
6. 胜利/失败结算使用独立面板,不在当前面板下方展开。
## 11.5 本地 mock 口径 ## 11.5 本地 mock 口径

View File

@@ -19,7 +19,7 @@
1. 现有 `Match3DVisualIcon``Match3DToken` 和托盘 2D 图案渲染代码必须保留。 1. 现有 `Match3DVisualIcon``Match3DToken` 和托盘 2D 图案渲染代码必须保留。
2. 新增 3D 表现层只作为运行态棋盘的可选渲染分支。 2. 新增 3D 表现层只作为运行态棋盘的可选渲染分支。
3. 当浏览器不支持 WebGL、3D 依赖加载失败或实验开关关闭时,运行态必须自动回到现有 2D 图案表现。 3. 当浏览器不支持 WebGL、3D 依赖加载失败或实验开关关闭时,运行态必须自动回到现有 2D 图案表现。
4. 托盘继续使用当前 2D 图标,便于玩家识别已选物品,也便于实验失败时快速回滚 4. 3D 模式下,托盘直接复用场内同一套程序化 3D 模型,以固定斜 `45` 度识别视角展示已选物品托盘内物品不进入物理世界不参与碰撞。WebGL 不可用或实验回退时,托盘继续使用当前 2D 图标。
## 3. 工程落点 ## 3. 工程落点
@@ -50,7 +50,7 @@ cannon-es
3D 分支只读取后端快照中的物品坐标、层级、可点击状态和视觉键。物理碰撞、轻微堆叠和几何体姿态只作为前端表现层,不改变消除规则、备选栏规则、胜负判定或最终权威快照。 3D 分支只读取后端快照中的物品坐标、层级、可点击状态和视觉键。物理碰撞、轻微堆叠和几何体姿态只作为前端表现层,不改变消除规则、备选栏规则、胜负判定或最终权威快照。
`match3dVisualAssets.tsx` 保留 2D 纯色几何图案映射,运行态托盘继续使用该 2D 图标;`match3dRuntimePresentation.ts` 收口显示层坐标和状态兼容,避免异常旧坐标把 2D 或 3D 物体推到圆形边界外。 `match3dVisualAssets.tsx` 保留 2D 纯色几何图案映射,运行态托盘在 3D 模式下通过 `Match3DTrayPreviewBoard` 使用单个共享 WebGL 预览层复用 `createMatch3DItemMesh` 生成同款 3D 模型,不能为每个托盘格单独创建 `WebGLRenderer`。WebGL 不可用或 2D 回退时继续使用该 2D 图标;`match3dRuntimePresentation.ts` 收口显示层坐标和状态兼容,避免异常旧坐标把 2D 或 3D 物体推到圆形边界外。
## 4. 验收口径 ## 4. 验收口径
@@ -58,8 +58,10 @@ cannon-es
2. 3D 几何体保持在圆形区域内,不被圆形边界裁切到不可点。 2. 3D 几何体保持在圆形区域内,不被圆形边界裁切到不可点。
3. 物体进入场景后有轻微物理碰撞和堆叠稳定过程。 3. 物体进入场景后有轻微物理碰撞和堆叠稳定过程。
4. 点击 3D 物体后仍执行原有乐观入槽、后端确认、三消反馈和结算。 4. 点击 3D 物体后仍执行原有乐观入槽、后端确认、三消反馈和结算。
5. 单元测试仍覆盖 2D 回退图案,确保回退路径没有被删除 5. 被取出的 3D 物体必须立即从棋盘物理世界移除;备选栏展示的是无碰撞、固定角度的独立预览模型,不允许继续受场内碰撞、重力或堆叠影响
6. 390px 移动端与桌面端均不能出现横向溢出,顶部状态、圆形棋盘和 7 格备选栏都要完整可见 6. 托盘 3D 预览必须共享一个 renderer避免多个 WebGL 上下文导致中心棋盘上下文被浏览器回收;中心棋盘监听 `webglcontextlost`,丢失时自动回退 2D 表现,禁止出现模型不可见但仍可点击的状态
7. 单元测试仍覆盖 2D 回退图案,确保回退路径没有被删除。
8. 390px 移动端与桌面端均不能出现横向溢出,顶部状态、圆形棋盘和 7 格备选栏都要完整可见。
## 5. 锅型容器优化 ## 5. 锅型容器优化
@@ -72,3 +74,88 @@ cannon-es
3. 物理世界使用同一个锅内半径作为水平活动边界,所有可消除物体的初始位置和运行中位置都必须被约束在圆形锅内。 3. 物理世界使用同一个锅内半径作为水平活动边界,所有可消除物体的初始位置和运行中位置都必须被约束在圆形锅内。
4. 物体受到重力后只允许在锅内碰撞、滑动、翻滚和向上堆叠,不能因为碰撞或初始坐标散落到圆形区域外。 4. 物体受到重力后只允许在锅内碰撞、滑动、翻滚和向上堆叠,不能因为碰撞或初始坐标散落到圆形区域外。
5. 该优化仍只属于前端 3D 表现层,不改变后端运行态坐标、点击权威判定、备选栏、消除和胜负规则。 5. 该优化仍只属于前端 3D 表现层,不改变后端运行态坐标、点击权威判定、备选栏、消除和胜负规则。
## 6. 中心引力优化
2026-05-02 追加中心引力,用来解决高消除次数下 3D 物体过于松散、贴边后被圆形场地裁切的问题。体验后发现默认向心力会让模型过度挤压成团,因此当前先关闭默认引力,只保留代码开关,后续如需再尝试可重新调参。
编码口径:
1. 中心引力默认系数为 `0`,默认不对物理 body 施加水平向心力。
2. 引力只作用在 X/Z 平面,不改变垂直重力,物体仍会自然落到锅底或堆叠在其他物体上。
3. 引力在越靠近锅边时越明显,避免大量物体碰撞后形成稀疏外环;靠近中心时力度收敛,避免所有物体被吸成单点。
4. 锅内活动边界继续作为硬约束;高数量物体应被锅边挡住并向上堆叠,不允许散落到圆形场地外。
5. `/match3d?clearCount=100` 可作为本地直达压力测试入口,用于验证 300 个物体时仍在锅内聚拢。
## 7. 正交俯视与真实场地边界
2026-05-02 针对高堆叠时 3D 物体被 DOM 圆形裁切的问题,明确中心圆形区域不是裁切蒙版,而是游戏实际游玩场地。
编码口径:
1. 3D 棋盘使用正交俯视相机,避免高处物体因为透视放大而投影到圆形场地外。
2. 圆形场地的内圈圆环对应 3D 世界里的锅内空气墙,物体范围由物理约束控制,不再依赖 DOM `overflow-hidden` 裁切。
3. 外层圆形 UI 只负责显示锅沿和场地外观,不能把物体裁成半截;如果物体看起来越界,优先修正相机、物理半径和空气墙。
4. 高数量压力测试以 `/match3d?clearCount=100` 为基准,物体可以在场地内向上堆叠,但不能被圆形边缘压住或切掉。
## 8. 类型数量与样式池历史口径
2026-05-03 曾调整消除物类型生成规则,解决 3D 关卡中可消除物类型和样式过少的问题。该节为历史口径,后续实际实现以第 11 节 25 个积木件资源池为准。
编码口径:
1. 历史版本曾使用 20 类形状颜色组合。
2. 当前版本已替换为 25 个积木件,旧 20 类上限不再作为编码依据。
3. 3D 与 2D 回退仍共用视觉键映射,新增样式不能破坏 `?match3dRender=2d` 回退路径。
## 9. 特殊形状 3D 可读性修正
2026-05-03 针对 20 组关卡中看不到十字、圆环、盾形、闪电、月牙、箭头等新形状的问题,补充 3D 几何体渲染口径。
编码口径:
1. 数据层仍使用 `visualKey` 决定类型,不新增贴图素材或文本标识。
2. 十字、心形、星形、圆环、盾形、闪电、月牙、箭头、V 形等特殊形状不能继续使用普通盒子、球体或锥体代理,必须生成俯视角可辨认的 3D 轮廓。
3. 特殊形状使用 Three.js 程序化轮廓挤出生成,保持当前 3D 实验可快速回退,不影响现有 2D 图案分支。
4. 特殊形状的物理碰撞可以继续使用近似碰撞体,但显示网格需要固定为俯视可读姿态,避免落地翻滚后又变成长方块或普通三角体。
5. 当前特殊形状已被 25 个积木件资源池替换;不能为了让玩家开局肉眼看到全部类型而改动初始层级、物理堆叠、遮挡、边界或可点击规则。
## 10. 15 组中档局面的类型唯一性修正
2026-05-03 针对 `clearCount=15` 时可消除物类型不足 15 种的问题,补充中档局面的规则验收口径。
编码口径:
1. `clearCount=15` 时,运行态数据中必须生成 `15` 种不同 `itemTypeId`,且首个 `15``visualKey` 必须分别对应不同几何形状。
2. 每种 `itemTypeId``clearCount=15` 时只对应 1 次消除目标,即恰好生成 `3` 件物体;同一种视觉模型在同局中不应出现超过 3 件。
3. 不为了展示 15 种而修改初始层级、物理堆叠、遮挡、边界或可点击规则;被盖住、堆叠和局部不可见是正常玩法效果。
4. 当前版本已改为第 11 节的 `itemTypeCount = clearCount <= 25 ? clearCount : 25` 规则。
## 11. 25 个积木件资源池替换
2026-05-03 根据新的参考图,把可消除物体替换为 25 个积木件类型,并调整本局类型抽取规则。
编码口径:
1. 默认 `visualKey` 资源池改为 25 个积木件覆盖长条、短条、2x2、2x3、2x4、1x1、光板、斜坡、圆柱、透明圆环、拱门和锥形件等差异化模型。
2. 前端 3D 表现继续使用 Three.js 程序化几何体生成,不引入外部贴图或 GLB托盘和 2D 回退继续使用同一批 `visualKey` 的简化图标。
3. `clearCount <= 25` 时,本局从 25 个类型中按确定性随机顺序抽取 `clearCount` 种类型,不允许同局刷新重复类型。
4. `clearCount > 25` 时,本局最多使用 25 种类型,额外消除组在这 25 种中轮转复用;每种类型最终数量仍必须是 3 的倍数。
5. 该随机抽取只决定本局使用哪些类型和使用顺序,不改变物理堆叠、遮挡、边界、可点击判定、备选栏和胜负规则。
6. 前端本地试玩、创作后试玩和后端权威运行态必须使用同一套 `itemTypeCount = clearCount <= 25 ? clearCount : 25` 口径。
## 12. 五档体积规则
2026-05-03 追加可消除物模型大小规则,把每局可消除物按五档相对体积分配。
编码口径:
1. M 型作为标准体积 `1.00`
2. XL 型相对体积范围为 `1.60~2.30`,占本局可消除类型数的 `20%`
3. L 型相对体积范围为 `1.25~1.60`,占本局可消除类型数的 `30%`
4. M 型相对体积固定为 `1.00`,占本局可消除类型数的 `30%`
5. XS 型相对体积范围为 `0.65~0.85`,占本局可消除类型数的 `15%`
6. S 型相对体积范围为 `0.35~0.50`,占本局可消除类型数的 `5%`
7. 本局使用类型数仍按第 11 节计算,即 `clearCount <= 25 ? clearCount : 25`。比例遇到非整数时按最大余数补齐,确保五档数量之和等于本局使用类型数。
8. 体积档位分配绑定到本局选中的 `visualKey`,同一局内同一个颜色和造型只能有一个尺寸档位和一个半径;当 `clearCount > 25` 轮转复用类型时,复用的同一 `visualKey` 继续沿用同一尺寸。
9. 前端本地试玩、创作后试玩和后端权威运行态必须使用同一套五档体积分配口径。

View File

@@ -202,10 +202,45 @@ Jenkins 可运行在 Windows 或其他机器上,本机 Windows 只作为人工
- Jenkins Job 参数不暴露真实节点名、IP 或带 IP 的标签。 - Jenkins Job 参数不暴露真实节点名、IP 或带 IP 的标签。
- 生产机已作为独立 Linux Jenkins agent 接入,节点名使用脱敏名称 `genarrative-release-deploy-01`,调度标签只使用 `linux``genarrative-release-deploy` - 生产机已作为独立 Linux Jenkins agent 接入,节点名使用脱敏名称 `genarrative-release-deploy-01`,调度标签只使用 `linux``genarrative-release-deploy`
- 生产机真实连接地址只允许保存在 Jenkins 节点 SSH launcher 的 `host` 字段中不能写入节点名、调度标签、Job 参数默认值或文档推荐命令 - 生产机 agent 启动方式统一改为 inbound agent + systemd 自守护,不再依赖 Jenkins controller 通过 SSH launcher 长期拉起。SSH 只作为首次登录和安装 systemd 服务的运维通道
- 生产机真实连接地址只允许保存在 Jenkins 节点连接配置或人工运维 SSH 配置中不能写入节点名、调度标签、Job 参数默认值或文档推荐命令。
- 发布 Job 通过 `DEPLOY_TARGET` 选择逻辑部署目标,再在 Jenkinsfile 内部映射到 Linux-only 脱敏调度表达式:`development -> linux && genarrative-build``release -> linux && genarrative-release-deploy` - 发布 Job 通过 `DEPLOY_TARGET` 选择逻辑部署目标,再在 Jenkinsfile 内部映射到 Linux-only 脱敏调度表达式:`development -> linux && genarrative-build``release -> linux && genarrative-release-deploy`
- 用途:服务器配置、发布静态网站、发布 `api-server`、发布 SpacetimeDB 模块、数据库导入导出、维护模式切换。 - 用途:服务器配置、发布静态网站、发布 `api-server`、发布 SpacetimeDB 模块、数据库导入导出、维护模式切换。
### Jenkins inbound agent 自恢复
发布 agent 必须由目标 Linux 机器主动连接 Jenkins controller并由 systemd 托管:
- Jenkins 节点 Launch method 使用 inbound agent优先启用 WebSocket。这样目标机只需要能访问 Jenkins Web 地址,不依赖 controller 每次 SSH 拉起 agent。
- 目标机安装 `deploy/systemd/jenkins-agent@.service``scripts/deploy/jenkins-inbound-agent-start.sh``scripts/deploy/install-jenkins-inbound-agent.sh`
- systemd 服务名采用 `jenkins-agent@<node-name>.service`,例如 `jenkins-agent@genarrative-release-deploy-01.service`
- systemd 自身 `WorkingDirectory` 保持 `/var/lib/jenkins/agent/<node-name>`Jenkins remoting `-workDir` 可继续使用旧 SSH agent 的 `/root/jenkins-agent`,避免迁移时 workspace 和缓存路径漂移。
- inbound secret 只能放在目标机 `/etc/jenkins-agent/<node-name>.secret` 或等价 Secret Text 注入位置,不能提交到 Git也不能写入 Jenkinsfile 默认参数。
- systemd unit 使用 `Restart=always``RestartSec=10`agent Java 进程退出、网络短断或机器重启后由 systemd 自动恢复,不需要人工盯着 Jenkins 页面手动重启。
- 当前 `Genarrative-Server-Provision` 仍负责 systemd、Nginx、`/opt/genarrative``/etc/genarrative` 等特权写入,因此 inbound agent 默认仍按现有 root 执行口径迁移。若后续改为 `jenkins` 用户运行 agent必须先把生产流水线需要的特权命令收敛为精确 `NOPASSWD` sudoers 白名单。
如果 Jenkins controller 只运行在本地 Windows不直接对目标机暴露公网地址需要在本地控制机启动 `scripts/deploy/jenkins-agent-reverse-tunnel.ps1`。该脚本通过同一条 SSH 会话把远端 `127.0.0.1:18080` 转到本地 Jenkins Web `127.0.0.1:8080`,把远端 `127.0.0.1:50000` 转到本地 Jenkins inbound TCP agent port `127.0.0.1:50000`,并在隧道断开后自动重试。此时远端 agent 的 `JENKINS_URL` 固定写 `http://127.0.0.1:18080/`,不写本地 Windows 的 `127.0.0.1:8080`
本地反向隧道脚本不内置目标机地址;注册 Windows 计划任务时必须显式传入 `-RemoteHost <release-agent-host>`,真实 IP 或主机名只保存在本地计划任务配置中,不提交到 Git。
当 Jenkins controller 以本地 Windows `java -jar jenkins.war` 方式运行时,使用 `scripts/deploy/jenkins-local-controller-watchdog.ps1` 作为本地守护脚本。该脚本只保存本机 Java、`jenkins.war``JENKINS_HOME` 和端口路径,不保存 Jenkins 账号、密码、token 或 agent secret注册 Windows 计划任务后,脚本会在登录后检查 `8080` 是否已有 Jenkins 监听,若已有则监控现有 PID若进程退出或端口空闲则重新启动 Jenkins并固定 `--agentPort=50000` 供远端 inbound agent 连接。
首次迁移示例:
```bash
sudo install -m 0600 /tmp/genarrative-release-deploy-01.secret /etc/jenkins-agent/genarrative-release-deploy-01.secret
sudo scripts/deploy/install-jenkins-inbound-agent.sh \
--agent-name genarrative-release-deploy-01 \
--jenkins-url http://127.0.0.1:18080/ \
--secret-file /etc/jenkins-agent/genarrative-release-deploy-01.secret \
--workdir /root/jenkins-agent \
--java-bin /usr/bin/java
sudo systemctl status jenkins-agent@genarrative-release-deploy-01.service --no-pager -l
journalctl -u jenkins-agent@genarrative-release-deploy-01.service -f
```
如果 Jenkins controller 暂时仍配置为 SSH launcher只能作为过渡方案使用需要把 SSH launch timeout 拉长、增加 retry 和 retry wait、固定 Java 路径,并确认 `ssh user@host 'java -version'` 稳定返回。最终仍要切到 inbound + systemd避免 SSH 连接卡住时阻塞发布队列。
### Git 仓库访问 ### Git 仓库访问
Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆成两层配置: Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆成两层配置:
@@ -538,6 +573,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module
- [x] `deploy/systemd/spacetimedb.service` - [x] `deploy/systemd/spacetimedb.service`
- [x] `deploy/systemd/genarrative-api.service` - [x] `deploy/systemd/genarrative-api.service`
- [x] `deploy/systemd/jenkins-agent@.service`
- [x] `deploy/nginx/genarrative.conf` - [x] `deploy/nginx/genarrative.conf`
- [x] `deploy/nginx/genarrative-dev-http.conf` - [x] `deploy/nginx/genarrative-dev-http.conf`
- [x] `deploy/nginx/snippets/genarrative-maintenance.conf` - [x] `deploy/nginx/snippets/genarrative-maintenance.conf`
@@ -545,6 +581,10 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module
- [x] `scripts/deploy/maintenance-on.sh` - [x] `scripts/deploy/maintenance-on.sh`
- [x] `scripts/deploy/maintenance-off.sh` - [x] `scripts/deploy/maintenance-off.sh`
- [x] `scripts/deploy/maintenance-status.sh` - [x] `scripts/deploy/maintenance-status.sh`
- [x] `scripts/deploy/jenkins-local-controller-watchdog.ps1`
- [x] `scripts/deploy/jenkins-agent-reverse-tunnel.ps1`
- [x] `scripts/deploy/jenkins-inbound-agent-start.sh`
- [x] `scripts/deploy/install-jenkins-inbound-agent.sh`
- [x] `scripts/build-production-release.sh` - [x] `scripts/build-production-release.sh`
- [x] `scripts/jenkins-checkout-source.sh` - [x] `scripts/jenkins-checkout-source.sh`
- [x] `scripts/deploy/production-web-deploy.sh` - [x] `scripts/deploy/production-web-deploy.sh`

View File

@@ -0,0 +1,78 @@
# 个人任务与埋点系统技术方案
更新时间:`2026-05-03`
## 1. 目标
本轮新增一套可配置的个人任务系统,并补齐任务依赖的埋点统计能力。首个任务为“每日登录”,奖励 `10` 光点,入口放在“我的”页签;后台可修改任务配置。
## 2. 核心边界
- 埋点原始事实写入 `tracking_event`,这是实际存在的 SpacetimeDB 表。
- 聚合投影写入 `tracking_daily_stat`,这也是后端维护的真实表,不是 view。
- 任务配置写入 `profile_task_config`,默认配置包含 `daily_login`,后台修改后不得被默认初始化覆盖。
- 任务进度写入 `profile_task_progress`,用于任务中心快速读取状态。
- 领奖记录写入 `profile_task_reward_claim`,与钱包流水 `profile_wallet_ledger` 同事务写入。
- “星光”奖励复用现有“光点”钱包,不新增第二种货币。
## 3. 埋点分层
| 层级 | scope_kind | scope_id 口径 |
| --- | --- | --- |
| 整站 | `site` | 固定为 `site` 或站点分区 key |
| 作品 | `work` | 作品 profile_id / work_id |
| 模块 | `module` | 模块 key例如 `profile``puzzle` |
| 用户 | `user` | 用户 id |
每条埋点可同时记录 `user_id``owner_user_id``profile_id``module_key``metadata_json`。任务首版只依赖用户层 `daily_login`,表结构先保留四层统计能力。
## 4. 日期桶
任务统计使用北京时间自然日:`day_key = floor((occurred_at_micros + 8h) / 1d)`
这样存储仍是 UTC 时间戳,日切规则固定为业务口径,不依赖服务器本地时区。`tracking_event.occurred_at` 保存精确发生时间,`tracking_daily_stat.day_key` 只承担聚合桶职责。
## 5. 首版任务
| 字段 | 默认值 |
| --- | --- |
| task_id | `daily_login` |
| title | `每日登录` |
| event_key | `daily_login` |
| cycle | `daily` |
| threshold | `1` |
| reward_points | `10` |
| enabled | `true` |
用户打开任务中心时,后端会幂等记录当日 `daily_login` 埋点并刷新任务进度。用户点击领取时,后端校验当日进度、领奖记录和配置状态,然后同事务写入领奖记录与钱包流水。
后台任务配置页的 `Event Key` 使用可搜索下拉控件,选项来自前端后台的埋点定义注册表。当前注册表默认包含 `daily_login`,展示中文名称和备注;后续新增任务依赖的埋点时,应先补充注册表,再开放运营配置。
## 6. 接口
### 用户侧
- `GET /api/profile/tasks`:读取任务中心,同时记录当日登录埋点。
- `POST /api/profile/tasks/{task_id}/claim`:领取任务奖励。
### 后台侧
- `GET /admin/api/profile/tasks`:读取任务配置列表。
- `POST /admin/api/profile/tasks`:新增或更新任务配置。
- `POST /admin/api/profile/tasks/disable`:停用任务配置。
后台任务配置页进入时从 `profile_task_config` 对应的列表接口读取已有配置,点击列表项回填表单后仍通过同一个 upsert 接口修改原配置。最近一次保存结果可以保留为会话态提示,但不得作为任务配置列表的唯一来源。
## 7. 查询文档边界
- `docs/tracking/` 只存放具体埋点与埋点聚合查询,例如 `tracking_event``tracking_daily_stat` 的站点/作品/模块/用户查询。
- `docs/operations/` 存放运营核查查询,例如任务进度、领奖记录、钱包流水对账。
不要把任务进度、领奖记录或钱包对账查询塞进 `docs/tracking/`,它们不是埋点系统本身。
## 8. 验收
1. `profile_task_config` 默认存在 `daily_login`,后台可修改奖励、阈值、标题和启用状态。
2. “我的”页可以打开每日任务面板,登录后任务可领取 `10` 光点。
3. 重复打开任务中心不会重复增加领取资格,重复领奖不会重复发放。
4. 表目录、迁移白名单、Rust/TypeScript 契约和前端入口同步更新。

View File

@@ -4,6 +4,7 @@
## 文档列表 ## 文档列表
- [PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md](./PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md):冻结个人任务与埋点系统首版方案,明确 `tracking_event``tracking_daily_stat``profile_task_config`、任务进度、领奖记录和光点钱包流水的边界。
- [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md):冻结单机生产部署目标,从旧一体化启动脚本切到 Nginx、systemd 托管 SpacetimeDB 与 Rust `api-server`,并记录生产 Jenkins 流水线拆分计划和首批部署骨架。 - [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md):冻结单机生产部署目标,从旧一体化启动脚本切到 Nginx、systemd 托管 SpacetimeDB 与 Rust `api-server`,并记录生产 Jenkins 流水线拆分计划和首批部署骨架。
- [PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md](./PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md):记录拼图正式平台入口移动、交换、合并、拆分和通关裁决收回前端即时运行态,排行榜、下一关和游玩记录继续由后端持久化处理。 - [PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md](./PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md):记录拼图正式平台入口移动、交换、合并、拆分和通关裁决收回前端即时运行态,排行榜、下一关和游玩记录继续由后端持久化处理。
- [RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md](./RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md):记录 `agent-foundation-*-dossier-batch-*` 无搜索 Responses 请求超时后的本地养成档案兜底,避免底稿主链被尾部角色润色阶段阻断。 - [RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md](./RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md):记录 `agent-foundation-*-dossier-batch-*` 无搜索 Responses 请求超时后的本地养成档案兜底,避免底稿主链被尾部角色润色阶段阻断。

View File

@@ -24,7 +24,7 @@ spacetime sql <db> "SELECT * FROM custom_world_gallery_entry"
| --- | --- | | --- | --- |
| 运维迁移 | `database_migration_operator`, `database_migration_import_chunk` | | 运维迁移 | `database_migration_operator`, `database_migration_import_chunk` |
| 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` | | 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` |
| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_order`, `profile_save_archive` | | 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `tracking_event`, `tracking_daily_stat`, `profile_task_config`, `profile_task_progress`, `profile_task_reward_claim`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_order`, `profile_save_archive` |
| RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` | | RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` |
| 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` | | 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` |
| 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_event`, `puzzle_runtime_run`, `puzzle_leaderboard_entry` | | 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_event`, `puzzle_runtime_run`, `puzzle_leaderboard_entry` |
@@ -158,14 +158,72 @@ SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>';
SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>' ORDER BY created_at DESC; SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>' ORDER BY created_at DESC;
``` ```
### `tracking_event`
- 作用:埋点原始事件表,保存整站、作品、模块和用户层的原始事实。
- 结构:`event_id PK: String`, `event_key: String`, `scope_kind: RuntimeTrackingScopeKind`, `scope_id: String`, `day_key: i64`, `user_id: Option<String>`, `owner_user_id: Option<String>`, `profile_id: Option<String>`, `module_key: Option<String>`, `metadata_json: String`, `occurred_at: Timestamp`
- 索引:`event_key`, `(scope_kind, scope_id)`, `(user_id, occurred_at)`
```sql
SELECT * FROM tracking_event WHERE event_id = '<event_id>';
SELECT * FROM tracking_event WHERE event_key = '<event_key>' ORDER BY occurred_at DESC;
SELECT * FROM tracking_event WHERE scope_kind = 'User' AND scope_id = '<user_id>' ORDER BY occurred_at DESC;
```
### `tracking_daily_stat`
- 作用:埋点按北京时间自然日聚合后的真实表,供任务统计和快速查询使用。
- 结构:`stat_id PK: String`, `event_key: String`, `scope_kind: RuntimeTrackingScopeKind`, `scope_id: String`, `day_key: i64`, `count: u32`, `first_occurred_at: Timestamp`, `last_occurred_at: Timestamp`, `updated_at: Timestamp`
- 索引:`(event_key, day_key)`, `(scope_kind, scope_id, day_key)`
```sql
SELECT * FROM tracking_daily_stat WHERE stat_id = '<stat_id>';
SELECT * FROM tracking_daily_stat WHERE scope_kind = 'User' AND scope_id = '<user_id>' ORDER BY day_key DESC;
```
### `profile_task_config`
- 作用:个人任务配置表,后台可修改每日登录等任务的奖励、阈值、启用状态和排序。
- 结构:`task_id PK: String`, `title: String`, `description: String`, `event_key: String`, `cycle: RuntimeProfileTaskCycle`, `scope_kind: RuntimeTrackingScopeKind`, `threshold: u32`, `reward_points: u64`, `enabled: bool`, `sort_order: i32`, `created_by: String`, `created_at: Timestamp`, `updated_by: String`, `updated_at: Timestamp`
- 索引:主键 `task_id`
```sql
SELECT * FROM profile_task_config WHERE task_id = 'daily_login';
SELECT * FROM profile_task_config ORDER BY updated_at DESC;
```
### `profile_task_progress`
- 作用:个人任务进度表,保存用户在某个自然日的任务进度和状态快照。
- 结构:`progress_id PK: String`, `user_id: String`, `task_id: String`, `day_key: i64`, `progress_count: u32`, `threshold: u32`, `status: RuntimeProfileTaskStatus`, `updated_at: Timestamp`
- 索引:`user_id`, `(user_id, task_id)`
```sql
SELECT * FROM profile_task_progress WHERE user_id = '<user_id>' ORDER BY updated_at DESC;
SELECT * FROM profile_task_progress WHERE user_id = '<user_id>' AND task_id = 'daily_login' ORDER BY day_key DESC;
```
### `profile_task_reward_claim`
- 作用:个人任务领奖记录表,记录用户、任务、自然日、奖励和对应钱包流水。
- 结构:`claim_id PK: String`, `user_id: String`, `task_id: String`, `day_key: i64`, `reward_points: u64`, `wallet_ledger_id: String`, `claimed_at: Timestamp`
- 索引:`user_id`, `(user_id, task_id)`
```sql
SELECT * FROM profile_task_reward_claim WHERE user_id = '<user_id>' ORDER BY claimed_at DESC;
SELECT * FROM profile_task_reward_claim WHERE claim_id = '<user_id>:daily_login:<day_key>';
```
### `profile_redeem_code` ### `profile_redeem_code`
- 作用:运营发放的光点兑换码,支持公共码、唯一码和私有码。 - 作用:运营发放的光点兑换码,支持公共码、唯一码和私有码。
- 结构:`code PK: String`, `mode: RuntimeProfileRedeemCodeMode`, `reward_points: u64`, `max_uses: u32`, `global_used_count: u32`, `enabled: bool`, `allowed_user_ids: Vec<String>`, `created_by: String`, `created_at: Timestamp`, `updated_at: Timestamp` - 结构:`code PK: String`, `mode: RuntimeProfileRedeemCodeMode`, `reward_points: u64`, `max_uses: u32`, `global_used_count: u32`, `enabled: bool`, `allowed_user_ids: Vec<String>`, `created_by: String`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:主键 `code` - 索引:主键 `code`
- 后台读取:`GET /admin/api/profile/redeem-codes` 从该表返回已有兑换码,后台列表点击后通过 upsert 修改同一条记录。
```sql ```sql
SELECT * FROM profile_redeem_code WHERE code = '<CODE>'; SELECT * FROM profile_redeem_code WHERE code = '<CODE>';
SELECT * FROM profile_redeem_code ORDER BY updated_at DESC;
``` ```
### `profile_redeem_code_usage` ### `profile_redeem_code_usage`
@@ -181,13 +239,15 @@ SELECT * FROM profile_redeem_code_usage WHERE user_id = '<user_id>';
### `profile_invite_code` ### `profile_invite_code`
- 作用:用户邀请中心的邀请码主表,保存用户当前稳定邀请码。 - 作用:用户邀请中心的邀请码主表,也承载后台运营预置邀请码。
- 结构:`user_id PK: String`, `invite_code: String`, `created_at: Timestamp`, `updated_at: Timestamp` - 结构:`user_id PK: String`, `invite_code: String`, `metadata_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:主键 `user_id`,唯一索引 `invite_code` - 索引:主键 `user_id`,唯一索引 `invite_code`
- 后台读取:`GET /admin/api/profile/invite-codes` 只返回 `user_id``admin:` 开头的后台预置码;普通用户自己的邀请码不得进入后台运营列表。
```sql ```sql
SELECT * FROM profile_invite_code WHERE user_id = '<user_id>'; SELECT * FROM profile_invite_code WHERE user_id = '<user_id>';
SELECT * FROM profile_invite_code WHERE invite_code = '<invite_code>'; SELECT * FROM profile_invite_code WHERE invite_code = '<invite_code>';
SELECT * FROM profile_invite_code WHERE user_id LIKE 'admin:%' ORDER BY updated_at DESC;
``` ```
### `profile_referral_relation` ### `profile_referral_relation`

7
docs/tracking/README.md Normal file
View File

@@ -0,0 +1,7 @@
# 埋点查询
本目录只存放埋点原始事件和埋点聚合投影的本地查询手册。
- [TRACKING_QUERY_PLAYBOOK_2026-05-03.md](./TRACKING_QUERY_PLAYBOOK_2026-05-03.md)`tracking_event``tracking_daily_stat` 的整站、作品、模块、用户维度查询。
任务配置、任务进度、领奖记录和钱包对账查询放在 `docs/operations/`

View File

@@ -0,0 +1,50 @@
# 埋点查询手册
更新时间:`2026-05-03`
占位符说明:
- `<db>`SpacetimeDB 数据库名。
- `<event_key>`:埋点 key例如 `daily_login`
- `<day_key>`:北京时间自然日桶,按 `floor((occurred_at_micros + 8h) / 1d)` 计算。
## 原始事件
```powershell
spacetime sql <db> "SELECT * FROM tracking_event ORDER BY occurred_at DESC LIMIT 50"
spacetime sql <db> "SELECT * FROM tracking_event WHERE event_key = '<event_key>' ORDER BY occurred_at DESC LIMIT 50"
```
## 整站维度
```powershell
spacetime sql <db> "SELECT * FROM tracking_daily_stat WHERE scope_kind = 'Site' AND scope_id = 'site' ORDER BY day_key DESC"
```
## 作品维度
```powershell
spacetime sql <db> "SELECT * FROM tracking_event WHERE scope_kind = 'Work' AND scope_id = '<profile_id>' ORDER BY occurred_at DESC LIMIT 50"
spacetime sql <db> "SELECT * FROM tracking_daily_stat WHERE scope_kind = 'Work' AND scope_id = '<profile_id>' ORDER BY day_key DESC"
```
## 模块维度
```powershell
spacetime sql <db> "SELECT * FROM tracking_event WHERE scope_kind = 'Module' AND scope_id = '<module_key>' ORDER BY occurred_at DESC LIMIT 50"
spacetime sql <db> "SELECT * FROM tracking_daily_stat WHERE scope_kind = 'Module' AND scope_id = '<module_key>' ORDER BY day_key DESC"
```
## 用户维度
```powershell
spacetime sql <db> "SELECT * FROM tracking_event WHERE scope_kind = 'User' AND scope_id = '<user_id>' ORDER BY occurred_at DESC LIMIT 50"
spacetime sql <db> "SELECT * FROM tracking_daily_stat WHERE scope_kind = 'User' AND scope_id = '<user_id>' ORDER BY day_key DESC"
```
## 每日登录埋点
```powershell
spacetime sql <db> "SELECT * FROM tracking_event WHERE event_key = 'daily_login' AND user_id = '<user_id>' ORDER BY occurred_at DESC LIMIT 20"
spacetime sql <db> "SELECT * FROM tracking_daily_stat WHERE event_key = 'daily_login' AND scope_kind = 'User' AND scope_id = '<user_id>' ORDER BY day_key DESC"
```

View File

@@ -66,7 +66,8 @@ export type ProfileWalletLedgerEntry = {
| 'asset_operation_consume' | 'asset_operation_consume'
| 'asset_operation_refund' | 'asset_operation_refund'
| 'redeem_code_reward' | 'redeem_code_reward'
| 'puzzle_author_incentive_claim'; | 'puzzle_author_incentive_claim'
| 'daily_task_reward';
createdAt: string; createdAt: string;
}; };
@@ -186,6 +187,116 @@ export type RedeemProfileRewardCodeResponse = {
ledgerEntry: ProfileWalletLedgerEntry; ledgerEntry: ProfileWalletLedgerEntry;
}; };
export type ProfileTaskCycle = 'daily';
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user';
export type ProfileTaskStatus =
| 'incomplete'
| 'claimable'
| 'claimed'
| 'disabled';
export type ProfileTaskItem = {
taskId: string;
title: string;
description: string;
eventKey: string;
cycle: ProfileTaskCycle;
threshold: number;
progressCount: number;
rewardPoints: number;
status: ProfileTaskStatus;
dayKey: number;
claimedAt: string | null;
updatedAt: string;
};
export type ProfileTaskCenterResponse = {
dayKey: number;
walletBalance: number;
tasks: ProfileTaskItem[];
updatedAt: string;
};
export type ClaimProfileTaskRewardResponse = {
taskId: string;
dayKey: number;
rewardPoints: number;
walletBalance: number;
ledgerEntry: ProfileWalletLedgerEntry;
center: ProfileTaskCenterResponse;
};
export type ProfileTaskConfigAdminResponse = {
taskId: string;
title: string;
description: string;
eventKey: string;
cycle: ProfileTaskCycle;
scopeKind: TrackingScopeKind;
threshold: number;
rewardPoints: number;
enabled: boolean;
sortOrder: number;
createdBy: string;
createdAt: string;
updatedBy: string;
updatedAt: string;
};
export type ProfileTaskConfigAdminListResponse = {
entries: ProfileTaskConfigAdminResponse[];
};
export type AdminUpsertProfileTaskConfigRequest = {
taskId: string;
title: string;
description?: string | null;
eventKey: string;
cycle: ProfileTaskCycle;
scopeKind: TrackingScopeKind;
threshold: number;
rewardPoints: number;
enabled?: boolean;
sortOrder?: number;
};
export type AdminDisableProfileTaskConfigRequest = {
taskId: string;
};
export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
export type ProfileRedeemCodeAdminResponse = {
code: string;
mode: ProfileRedeemCodeMode;
rewardPoints: number;
maxUses: number;
globalUsedCount: number;
enabled: boolean;
allowedUserIds: string[];
createdBy: string;
createdAt: string;
updatedAt: string;
};
export type ProfileRedeemCodeAdminListResponse = {
entries: ProfileRedeemCodeAdminResponse[];
};
export type AdminUpsertProfileRedeemCodeRequest = {
code: string;
mode: ProfileRedeemCodeMode;
rewardPoints: number;
maxUses: number;
enabled?: boolean;
allowedUserIds?: string[];
allowedPublicUserCodes?: string[];
};
export type AdminDisableProfileRedeemCodeRequest = {
code: string;
};
export type AdminUpsertProfileInviteCodeRequest = { export type AdminUpsertProfileInviteCodeRequest = {
inviteCode: string; inviteCode: string;
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
@@ -199,6 +310,10 @@ export type ProfileInviteCodeAdminResponse = {
updatedAt: string; updatedAt: string;
}; };
export type ProfileInviteCodeAdminListResponse = {
entries: ProfileInviteCodeAdminResponse[];
};
export type ProfilePlayedWorkSummary = { export type ProfilePlayedWorkSummary = {
worldKey: string; worldKey: string;
ownerUserId: string | null; ownerUserId: string | null;

View File

@@ -370,10 +370,16 @@ mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/deploy"
cp "${SCRIPT_DIR}/deploy/maintenance-on.sh" "${TARGET_DIR}/scripts/maintenance-on.sh" cp "${SCRIPT_DIR}/deploy/maintenance-on.sh" "${TARGET_DIR}/scripts/maintenance-on.sh"
cp "${SCRIPT_DIR}/deploy/maintenance-off.sh" "${TARGET_DIR}/scripts/maintenance-off.sh" cp "${SCRIPT_DIR}/deploy/maintenance-off.sh" "${TARGET_DIR}/scripts/maintenance-off.sh"
cp "${SCRIPT_DIR}/deploy/maintenance-status.sh" "${TARGET_DIR}/scripts/maintenance-status.sh" cp "${SCRIPT_DIR}/deploy/maintenance-status.sh" "${TARGET_DIR}/scripts/maintenance-status.sh"
cp "${SCRIPT_DIR}/deploy/jenkins-inbound-agent-start.sh" "${TARGET_DIR}/scripts/jenkins-inbound-agent-start.sh"
cp "${SCRIPT_DIR}/deploy/install-jenkins-inbound-agent.sh" "${TARGET_DIR}/scripts/install-jenkins-inbound-agent.sh"
cp "${SCRIPT_DIR}/deploy/jenkins-agent-reverse-tunnel.ps1" "${TARGET_DIR}/scripts/jenkins-agent-reverse-tunnel.ps1"
cp "${SCRIPT_DIR}/deploy/jenkins-local-controller-watchdog.ps1" "${TARGET_DIR}/scripts/jenkins-local-controller-watchdog.ps1"
chmod +x \ chmod +x \
"${TARGET_DIR}/scripts/maintenance-on.sh" \ "${TARGET_DIR}/scripts/maintenance-on.sh" \
"${TARGET_DIR}/scripts/maintenance-off.sh" \ "${TARGET_DIR}/scripts/maintenance-off.sh" \
"${TARGET_DIR}/scripts/maintenance-status.sh" "${TARGET_DIR}/scripts/maintenance-status.sh" \
"${TARGET_DIR}/scripts/jenkins-inbound-agent-start.sh" \
"${TARGET_DIR}/scripts/install-jenkins-inbound-agent.sh"
copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET_DIR}/scripts/database-export.mjs" "数据库导出脚本" copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET_DIR}/scripts/database-export.mjs" "数据库导出脚本"
copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本" copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本"
@@ -398,7 +404,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
- \`spacetime_module.wasm\`SpacetimeDB 模块 wasm。 - \`spacetime_module.wasm\`SpacetimeDB 模块 wasm。
- \`*.sha256\`:发布产物 checksum用于部署前校验。 - \`*.sha256\`:发布产物 checksum用于部署前校验。
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。 - \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
- \`scripts/\`:维护模式脚本、数据库导入导出脚本迁移授权脚本。 - \`scripts/\`:维护模式脚本、数据库导入导出脚本迁移授权脚本和 Jenkins inbound agent systemd 安装脚本
- \`deploy/\`systemd、Nginx 和生产环境变量示例;\`deploy/nginx/genarrative-dev-http.conf\` 仅供无域名开发服初始化使用。 - \`deploy/\`systemd、Nginx 和生产环境变量示例;\`deploy/nginx/genarrative-dev-http.conf\` 仅供无域名开发服初始化使用。
## 生产部署口径 ## 生产部署口径

View File

@@ -0,0 +1,218 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat >&2 <<'EOF'
用法:
sudo scripts/deploy/install-jenkins-inbound-agent.sh \
--agent-name genarrative-release-deploy-01 \
--jenkins-url http://<jenkins-controller>:8080/ \
--secret-file /path/to/inbound-agent.secret
可选参数:
--run-user <user> systemd 运行用户,默认 root当前生产流水线仍需要特权操作。
--run-group <group> systemd 运行用户组,默认跟随 --run-user。
--workdir <path> agent 工作目录,默认 /var/lib/jenkins/agent/<agent-name>。
--jar-path <path> agent.jar 落盘路径,默认 /opt/jenkins-agent/agent.jar。
--java-bin <path> Java 命令路径,默认 java需要固定 JDK 时传绝对路径。
--no-websocket 不使用 WebSocket inbound 连接。
--no-enable 只安装 unit不执行 systemctl enable。
--no-start 只安装 unit不立即启动服务。
--dry-run 只打印操作,不写入系统。
密钥来源:
优先使用 --secret-file如果未传入则读取环境变量 JENKINS_AGENT_SECRET
如果目标机已存在 /etc/jenkins-agent/<agent-name>.secret则保留原密钥。
EOF
}
AGENT_NAME=""
JENKINS_URL_VALUE=""
SECRET_FILE=""
RUN_USER="root"
RUN_GROUP=""
WORKDIR=""
JAR_PATH="/opt/jenkins-agent/agent.jar"
JAVA_BIN="java"
USE_WEBSOCKET="true"
ENABLE_SERVICE="true"
START_SERVICE="true"
DRY_RUN="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--agent-name)
AGENT_NAME="${2:?缺少 --agent-name 的值}"
shift 2
;;
--jenkins-url)
JENKINS_URL_VALUE="${2:?缺少 --jenkins-url 的值}"
shift 2
;;
--secret-file)
SECRET_FILE="${2:?缺少 --secret-file 的值}"
shift 2
;;
--run-user)
RUN_USER="${2:?缺少 --run-user 的值}"
shift 2
;;
--run-group)
RUN_GROUP="${2:?缺少 --run-group 的值}"
shift 2
;;
--workdir)
WORKDIR="${2:?缺少 --workdir 的值}"
shift 2
;;
--jar-path)
JAR_PATH="${2:?缺少 --jar-path 的值}"
shift 2
;;
--java-bin)
JAVA_BIN="${2:?缺少 --java-bin 的值}"
shift 2
;;
--no-websocket)
USE_WEBSOCKET="false"
shift
;;
--no-enable)
ENABLE_SERVICE="false"
shift
;;
--no-start)
START_SERVICE="false"
shift
;;
--dry-run)
DRY_RUN="true"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "[jenkins-agent-install] 未知参数: $1" >&2
usage
exit 2
;;
esac
done
if [[ -z "${AGENT_NAME}" || -z "${JENKINS_URL_VALUE}" ]]; then
usage
exit 2
fi
if [[ -z "${RUN_GROUP}" ]]; then
RUN_GROUP="${RUN_USER}"
fi
if [[ -z "${WORKDIR}" ]]; then
WORKDIR="/var/lib/jenkins/agent/${AGENT_NAME}"
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
START_SOURCE="${SCRIPT_DIR}/jenkins-inbound-agent-start.sh"
UNIT_SOURCE="${REPO_ROOT}/deploy/systemd/jenkins-agent@.service"
CONFIG_DIR="/etc/jenkins-agent"
CONFIG_FILE="${CONFIG_DIR}/${AGENT_NAME}.env"
SECRET_TARGET="${CONFIG_DIR}/${AGENT_NAME}.secret"
SERVICE_NAME="jenkins-agent@${AGENT_NAME}.service"
run_cmd() {
echo "+ $*"
if [[ "${DRY_RUN}" != "true" ]]; then
"$@"
fi
}
write_file() {
local target="$1"
local mode="$2"
local owner="$3"
local group="$4"
local temp_file
temp_file="$(mktemp)"
cat >"${temp_file}"
echo "+ install -m ${mode} ${temp_file} ${target}"
if [[ "${DRY_RUN}" != "true" ]]; then
install -m "${mode}" -o "${owner}" -g "${group}" "${temp_file}" "${target}"
fi
rm -f "${temp_file}"
}
if [[ ! -f "${START_SOURCE}" ]]; then
echo "[jenkins-agent-install] 缺少启动脚本: ${START_SOURCE}" >&2
exit 1
fi
if [[ ! -f "${UNIT_SOURCE}" ]]; then
echo "[jenkins-agent-install] 缺少 systemd 模板: ${UNIT_SOURCE}" >&2
exit 1
fi
if [[ "${RUN_USER}" != "root" ]] && ! id "${RUN_USER}" >/dev/null 2>&1; then
run_cmd useradd --system --create-home --home-dir "/var/lib/${RUN_USER}" --shell /bin/bash "${RUN_USER}"
fi
run_cmd mkdir -p "${CONFIG_DIR}" "$(dirname "${JAR_PATH}")" "${WORKDIR}"
run_cmd chmod 0755 "${CONFIG_DIR}" "$(dirname "${JAR_PATH}")"
if [[ "${DRY_RUN}" != "true" ]]; then
chown -R "${RUN_USER}:${RUN_GROUP}" "$(dirname "${JAR_PATH}")" "${WORKDIR}"
fi
run_cmd install -m 0755 "${START_SOURCE}" /usr/local/bin/jenkins-inbound-agent-start
UNIT_TMP="$(mktemp)"
sed \
-e "s|^User=.*|User=${RUN_USER}|" \
-e "s|^Group=.*|Group=${RUN_GROUP}|" \
"${UNIT_SOURCE}" >"${UNIT_TMP}"
run_cmd install -m 0644 "${UNIT_TMP}" /etc/systemd/system/jenkins-agent@.service
rm -f "${UNIT_TMP}"
write_file "${CONFIG_FILE}" 0644 root root <<EOF
JENKINS_URL='${JENKINS_URL_VALUE}'
JENKINS_AGENT_NAME='${AGENT_NAME}'
JENKINS_AGENT_WORKDIR='${WORKDIR}'
JENKINS_AGENT_JAR='${JAR_PATH}'
JENKINS_AGENT_SECRET_FILE='${SECRET_TARGET}'
JENKINS_AGENT_USE_WEBSOCKET='${USE_WEBSOCKET}'
JENKINS_AGENT_JAVA_BIN='${JAVA_BIN}'
EOF
if [[ -n "${SECRET_FILE}" ]]; then
if [[ ! -r "${SECRET_FILE}" ]]; then
echo "[jenkins-agent-install] 密钥文件不可读: ${SECRET_FILE}" >&2
exit 1
fi
run_cmd install -m 0600 -o "${RUN_USER}" -g "${RUN_GROUP}" "${SECRET_FILE}" "${SECRET_TARGET}"
elif [[ -n "${JENKINS_AGENT_SECRET:-}" ]]; then
write_file "${SECRET_TARGET}" 0600 "${RUN_USER}" "${RUN_GROUP}" <<EOF
${JENKINS_AGENT_SECRET}
EOF
elif [[ -f "${SECRET_TARGET}" ]]; then
echo "[jenkins-agent-install] 已存在密钥文件,保留不覆盖: ${SECRET_TARGET}"
else
echo "[jenkins-agent-install] 缺少 inbound agent secret。请传 --secret-file或设置 JENKINS_AGENT_SECRET。" >&2
exit 1
fi
run_cmd systemctl daemon-reload
if [[ "${ENABLE_SERVICE}" == "true" ]]; then
run_cmd systemctl enable "${SERVICE_NAME}"
fi
if [[ "${START_SERVICE}" == "true" ]]; then
run_cmd systemctl restart "${SERVICE_NAME}"
run_cmd systemctl status "${SERVICE_NAME}" --no-pager -l
fi
echo "[jenkins-agent-install] 完成: ${SERVICE_NAME}"

View File

@@ -0,0 +1,50 @@
param(
[string]$RemoteHost = "",
[string]$RemoteUser = "root",
[string]$SshKeyPath = "$env:USERPROFILE\.ssh\dsk.pem",
[string]$LocalJenkinsHost = "127.0.0.1",
[int]$LocalJenkinsPort = 8080,
[int]$LocalAgentPort = 50000,
[int]$RemoteJenkinsPort = 18080,
[int]$RemoteAgentPort = 50000,
[int]$RestartDelaySeconds = 10
)
$ErrorActionPreference = "Stop"
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Write-Output "[$timestamp] $Message"
}
$ssh = (Get-Command ssh.exe -ErrorAction Stop).Source
$remote = "$RemoteUser@$RemoteHost"
if (-not $RemoteHost) {
throw "RemoteHost is required."
}
if (-not (Test-Path -LiteralPath $SshKeyPath)) {
throw "SSH key not found: $SshKeyPath"
}
while ($true) {
$args = @(
"-i", $SshKeyPath,
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ExitOnForwardFailure=yes",
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=3",
"-N",
"-R", "127.0.0.1:${RemoteJenkinsPort}:${LocalJenkinsHost}:${LocalJenkinsPort}",
"-R", "127.0.0.1:${RemoteAgentPort}:${LocalJenkinsHost}:${LocalAgentPort}",
$remote
)
Write-Log "Starting Jenkins agent reverse tunnel: $remote"
& $ssh @args
$exitCode = $LASTEXITCODE
Write-Log "Reverse tunnel exited, exitCode=$exitCode; retrying in ${RestartDelaySeconds}s."
Start-Sleep -Seconds $RestartDelaySeconds
}

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat >&2 <<'EOF'
用法:
jenkins-inbound-agent-start <agent-name>
说明:
该脚本由 systemd 调用,读取 /etc/jenkins-agent/<agent-name>.env
下载 Jenkins agent.jar并通过 inbound WebSocket 连接 Jenkins controller。
EOF
}
AGENT_INSTANCE="${1:-}"
if [[ -z "${AGENT_INSTANCE}" ]]; then
usage
exit 2
fi
CONFIG_FILE="${JENKINS_AGENT_CONFIG_FILE:-/etc/jenkins-agent/${AGENT_INSTANCE}.env}"
if [[ ! -r "${CONFIG_FILE}" ]]; then
echo "[jenkins-agent] 配置文件不可读: ${CONFIG_FILE}" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "${CONFIG_FILE}"
set +a
JENKINS_AGENT_NAME="${JENKINS_AGENT_NAME:-${AGENT_INSTANCE}}"
JENKINS_AGENT_WORKDIR="${JENKINS_AGENT_WORKDIR:-/var/lib/jenkins/agent/${JENKINS_AGENT_NAME}}"
JENKINS_AGENT_JAR="${JENKINS_AGENT_JAR:-/opt/jenkins-agent/agent.jar}"
JENKINS_AGENT_SECRET_FILE="${JENKINS_AGENT_SECRET_FILE:-/etc/jenkins-agent/${JENKINS_AGENT_NAME}.secret}"
JENKINS_AGENT_USE_WEBSOCKET="${JENKINS_AGENT_USE_WEBSOCKET:-true}"
JENKINS_AGENT_JAVA_BIN="${JENKINS_AGENT_JAVA_BIN:-java}"
if [[ -z "${JENKINS_URL:-}" ]]; then
echo "[jenkins-agent] JENKINS_URL 不能为空。" >&2
exit 1
fi
if [[ -z "${JENKINS_AGENT_SECRET:-}" ]]; then
if [[ ! -r "${JENKINS_AGENT_SECRET_FILE}" ]]; then
echo "[jenkins-agent] 未提供 JENKINS_AGENT_SECRET且密钥文件不可读: ${JENKINS_AGENT_SECRET_FILE}" >&2
exit 1
fi
JENKINS_AGENT_SECRET="$(tr -d '\r\n' <"${JENKINS_AGENT_SECRET_FILE}")"
fi
if [[ -z "${JENKINS_AGENT_SECRET}" ]]; then
echo "[jenkins-agent] Jenkins inbound agent secret 不能为空。" >&2
exit 1
fi
mkdir -p "$(dirname "${JENKINS_AGENT_JAR}")" "${JENKINS_AGENT_WORKDIR}"
AGENT_JAR_URL="${JENKINS_URL%/}/jnlpJars/agent.jar"
AGENT_JAR_TMP="${JENKINS_AGENT_JAR}.tmp"
echo "[jenkins-agent] 下载 agent.jar: ${AGENT_JAR_URL}"
curl -fsSL --retry 5 --retry-delay 5 "${AGENT_JAR_URL}" -o "${AGENT_JAR_TMP}"
mv "${AGENT_JAR_TMP}" "${JENKINS_AGENT_JAR}"
agent_args=(
"${JENKINS_AGENT_JAVA_BIN}"
-jar "${JENKINS_AGENT_JAR}"
-url "${JENKINS_URL}"
-secret "${JENKINS_AGENT_SECRET}"
-name "${JENKINS_AGENT_NAME}"
-workDir "${JENKINS_AGENT_WORKDIR}"
)
if [[ "${JENKINS_AGENT_USE_WEBSOCKET}" == "true" ]]; then
agent_args+=(-webSocket)
fi
echo "[jenkins-agent] 启动 inbound agent: ${JENKINS_AGENT_NAME}"
exec "${agent_args[@]}"

View File

@@ -0,0 +1,95 @@
param(
[string]$JavaPath = "$env:USERPROFILE\jenkins-local\jdk-21\jdk-21.0.11+10\bin\java.exe",
[string]$JenkinsWar = "$env:USERPROFILE\jenkins-local\jenkins.war",
[string]$JenkinsHome = "$env:USERPROFILE\.jenkins",
[int]$HttpPort = 8080,
[int]$AgentPort = 50000,
[int]$RestartDelaySeconds = 10
)
$ErrorActionPreference = "Stop"
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Write-Output "[$timestamp] $Message"
}
function Get-ListeningProcessId {
param([int]$Port)
$line = netstat -ano | Select-String -Pattern "LISTENING\s+(\d+)$" | Where-Object {
$_.Line -match "[:.]$Port\s+"
} | Select-Object -First 1
if (-not $line) {
return $null
}
if ($line.Line -match "LISTENING\s+(\d+)$") {
return [int]$Matches[1]
}
return $null
}
function Test-JenkinsProcess {
param([int]$ProcessId)
if (-not $ProcessId) {
return $false
}
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction SilentlyContinue
if (-not $process) {
return $false
}
return ($process.CommandLine -like "*jenkins.war*")
}
if (-not (Test-Path -LiteralPath $JavaPath)) {
throw "Java path not found: $JavaPath"
}
if (-not (Test-Path -LiteralPath $JenkinsWar)) {
throw "Jenkins war not found: $JenkinsWar"
}
New-Item -ItemType Directory -Force -Path $JenkinsHome | Out-Null
while ($true) {
$listeningPid = Get-ListeningProcessId -Port $HttpPort
if ($listeningPid -and (Test-JenkinsProcess -ProcessId $listeningPid)) {
Write-Log "Jenkins is already listening on port $HttpPort with pid $listeningPid; monitoring it."
try {
Wait-Process -Id $listeningPid
} catch {
Write-Log "Existing Jenkins process wait failed: $($_.Exception.Message)"
}
} elseif ($listeningPid) {
Write-Log "Port $HttpPort is occupied by pid $listeningPid, but it is not Jenkins. Retrying in ${RestartDelaySeconds}s."
Start-Sleep -Seconds $RestartDelaySeconds
continue
} else {
$arguments = @(
"-Djenkins.install.runSetupWizard=false",
"-jar", $JenkinsWar,
"--httpPort=$HttpPort",
"--agentPort=$AgentPort"
)
Write-Log "Starting local Jenkins controller on port $HttpPort."
$previousJenkinsHome = $env:JENKINS_HOME
$env:JENKINS_HOME = $JenkinsHome
$process = Start-Process -FilePath $JavaPath -ArgumentList $arguments -WorkingDirectory (Split-Path -Parent $JenkinsWar) -NoNewWindow -PassThru
$env:JENKINS_HOME = $previousJenkinsHome
try {
Wait-Process -Id $process.Id
} catch {
Write-Log "Started Jenkins process wait failed: $($_.Exception.Message)"
}
}
Write-Log "Jenkins controller stopped; retrying in ${RestartDelaySeconds}s."
Start-Sleep -Seconds $RestartDelaySeconds
}

View File

@@ -7,6 +7,7 @@ usage() {
用法: 用法:
npm run dev:rust npm run dev:rust
./scripts/dev-rust-stack.sh --api-port 8090 --spacetime-port 3110 ./scripts/dev-rust-stack.sh --api-port 8090 --spacetime-port 3110
./scripts/dev-rust-stack.sh --admin-web-port 3102
./scripts/dev-rust-stack.sh --api-timeout-seconds 600 ./scripts/dev-rust-stack.sh --api-timeout-seconds 600
./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish ./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish
./scripts/dev-rust-stack.sh --preserve-database ./scripts/dev-rust-stack.sh --preserve-database
@@ -14,7 +15,7 @@ usage() {
npm run dev:rust:logs -- --follow npm run dev:rust:logs -- --follow
说明: 说明:
1. 默认同时启动 SpacetimeDB standalone、Rust api-server Vite 前端 1. 默认同时启动 SpacetimeDB standalone、Rust api-server、主站 Vite 与后台 Vite。
2. 当前开发阶段默认 publish server-rs/crates/spacetime-module 时追加 -c=on-conflict 在结构冲突时清理旧模块数据。 2. 当前开发阶段默认 publish server-rs/crates/spacetime-module 时追加 -c=on-conflict 在结构冲突时清理旧模块数据。
3. 只有显式传入 --preserve-database 时,才会跳过 -c=on-conflict。 3. 只有显式传入 --preserve-database 时,才会跳过 -c=on-conflict。
4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local 作为本地数据与日志目录。 4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local 作为本地数据与日志目录。
@@ -296,11 +297,14 @@ SERVER_RS_DIR="${REPO_ROOT}/server-rs"
MANIFEST_PATH="${SERVER_RS_DIR}/Cargo.toml" MANIFEST_PATH="${SERVER_RS_DIR}/Cargo.toml"
MODULE_PATH="${SERVER_RS_DIR}/crates/spacetime-module" MODULE_PATH="${SERVER_RS_DIR}/crates/spacetime-module"
VITE_CLI_PATH="${REPO_ROOT}/scripts/vite-cli.mjs" VITE_CLI_PATH="${REPO_ROOT}/scripts/vite-cli.mjs"
ADMIN_WEB_DIR="${REPO_ROOT}/apps/admin-web"
API_HOST="127.0.0.1" API_HOST="127.0.0.1"
API_PORT="8082" API_PORT="8082"
WEB_HOST="0.0.0.0" WEB_HOST="0.0.0.0"
WEB_PORT="3000" WEB_PORT="3000"
ADMIN_WEB_HOST="127.0.0.1"
ADMIN_WEB_PORT="3102"
SPACETIME_HOST="127.0.0.1" SPACETIME_HOST="127.0.0.1"
SPACETIME_PORT="3101" SPACETIME_PORT="3101"
SPACETIME_ROOT_DIR="${SERVER_RS_DIR}/.spacetimedb/local" SPACETIME_ROOT_DIR="${SERVER_RS_DIR}/.spacetimedb/local"
@@ -359,6 +363,14 @@ while [[ $# -gt 0 ]]; do
WEB_PORT="${2:?缺少 --web-port 的值}" WEB_PORT="${2:?缺少 --web-port 的值}"
shift 2 shift 2
;; ;;
--admin-web-host)
ADMIN_WEB_HOST="${2:?缺少 --admin-web-host 的值}"
shift 2
;;
--admin-web-port)
ADMIN_WEB_PORT="${2:?缺少 --admin-web-port 的值}"
shift 2
;;
--spacetime-host) --spacetime-host)
SPACETIME_HOST="${2:?缺少 --spacetime-host 的值}" SPACETIME_HOST="${2:?缺少 --spacetime-host 的值}"
shift 2 shift 2
@@ -444,6 +456,11 @@ if [[ ! -f "${VITE_CLI_PATH}" ]]; then
exit 1 exit 1
fi fi
if [[ ! -f "${ADMIN_WEB_DIR}/package.json" ]]; then
echo "[dev:rust] 未找到 ${ADMIN_WEB_DIR}/package.json无法启动后台前端。" >&2
exit 1
fi
require_command cargo require_command cargo
require_command node require_command node
@@ -454,11 +471,13 @@ fi
SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}" SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}"
API_TARGET_HOST="$(resolve_client_host "${API_HOST}")" API_TARGET_HOST="$(resolve_client_host "${API_HOST}")"
RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}" RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}"
ADMIN_WEB_TARGET_HOST="$(resolve_client_host "${ADMIN_WEB_HOST}")"
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
echo "[dev:rust] repo: ${REPO_ROOT}" echo "[dev:rust] repo: ${REPO_ROOT}"
echo "[dev:rust] web: http://127.0.0.1:${WEB_PORT}" echo "[dev:rust] web: http://127.0.0.1:${WEB_PORT}"
echo "[dev:rust] admin web: http://${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}"
echo "[dev:rust] rust api: ${RUST_SERVER_TARGET}" echo "[dev:rust] rust api: ${RUST_SERVER_TARGET}"
echo "[dev:rust] spacetime: ${SPACETIME_SERVER}" echo "[dev:rust] spacetime: ${SPACETIME_SERVER}"
echo "[dev:rust] database: ${DATABASE}" echo "[dev:rust] database: ${DATABASE}"
@@ -537,12 +556,25 @@ echo "[dev:rust] 启动 vite"
cd "${REPO_ROOT}" cd "${REPO_ROOT}"
RUST_SERVER_TARGET="${RUST_SERVER_TARGET}" \ RUST_SERVER_TARGET="${RUST_SERVER_TARGET}" \
GENARRATIVE_RUNTIME_SERVER_TARGET="${RUST_SERVER_TARGET}" \ GENARRATIVE_RUNTIME_SERVER_TARGET="${RUST_SERVER_TARGET}" \
ADMIN_WEB_TARGET="http://${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}" \
ADMIN_WEB_PORT="${ADMIN_WEB_PORT}" \
VITE_DEV_HOST="${WEB_HOST}" \ VITE_DEV_HOST="${WEB_HOST}" \
exec node "${VITE_CLI_PATH}" "--port=${WEB_PORT}" "--host=${WEB_HOST}" exec node "${VITE_CLI_PATH}" "--port=${WEB_PORT}" "--host=${WEB_HOST}"
) & ) &
PIDS+=("$!") PIDS+=("$!")
NAMES+=("vite") NAMES+=("vite")
echo "[dev:rust] 启动 admin vite"
(
cd "${ADMIN_WEB_DIR}"
ADMIN_API_TARGET="${RUST_SERVER_TARGET}" \
GENARRATIVE_API_TARGET="${RUST_SERVER_TARGET}" \
GENARRATIVE_API_PORT="${API_PORT}" \
exec node "${VITE_CLI_PATH}" "--host=${ADMIN_WEB_HOST}" "--port=${ADMIN_WEB_PORT}"
) &
PIDS+=("$!")
NAMES+=("admin-vite")
echo "[dev:rust] 本地 Rust 栈已启动。按 Ctrl+C 停止全部子进程。" echo "[dev:rust] 本地 Rust 栈已启动。按 Ctrl+C 停止全部子进程。"
set +e set +e

View File

@@ -105,10 +105,14 @@ use crate::{
}, },
runtime_inventory::get_runtime_inventory_state, runtime_inventory::get_runtime_inventory_state,
runtime_profile::{ runtime_profile::{
admin_disable_profile_redeem_code, admin_upsert_profile_invite_code, admin_disable_profile_redeem_code, admin_disable_profile_task_config,
admin_upsert_profile_redeem_code, create_profile_recharge_order, get_profile_dashboard, admin_list_profile_invite_codes, admin_list_profile_redeem_codes,
admin_list_profile_task_configs, admin_upsert_profile_invite_code,
admin_upsert_profile_redeem_code, admin_upsert_profile_task_config,
claim_profile_task_reward, create_profile_recharge_order, get_profile_dashboard,
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center, get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code, get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code,
redeem_profile_reward_code,
}, },
runtime_save::{ runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
@@ -157,10 +161,12 @@ pub fn build_router(state: AppState) -> Router {
) )
.route( .route(
"/admin/api/profile/redeem-codes", "/admin/api/profile/redeem-codes",
post(admin_upsert_profile_redeem_code).route_layer(middleware::from_fn_with_state( get(admin_list_profile_redeem_codes)
state.clone(), .post(admin_upsert_profile_redeem_code)
require_admin_auth, .route_layer(middleware::from_fn_with_state(
)), state.clone(),
require_admin_auth,
)),
) )
.route( .route(
"/admin/api/profile/redeem-codes/disable", "/admin/api/profile/redeem-codes/disable",
@@ -171,7 +177,25 @@ pub fn build_router(state: AppState) -> Router {
) )
.route( .route(
"/admin/api/profile/invite-codes", "/admin/api/profile/invite-codes",
post(admin_upsert_profile_invite_code).route_layer(middleware::from_fn_with_state( get(admin_list_profile_invite_codes)
.post(admin_upsert_profile_invite_code)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/tasks",
get(admin_list_profile_task_configs)
.post(admin_upsert_profile_task_config)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/tasks/disable",
post(admin_disable_profile_task_config).route_layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
require_admin_auth, require_admin_auth,
)), )),
@@ -1057,6 +1081,20 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth, require_bearer_auth,
)), )),
) )
.route(
"/api/profile/tasks",
get(get_profile_task_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/tasks/{task_id}/claim",
post(claim_profile_task_reward).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route( .route(
"/api/profile/save-archives", "/api/profile/save-archives",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state( get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(

View File

@@ -1,6 +1,6 @@
use axum::{ use axum::{
Json, Json,
extract::{Extension, State}, extract::{Extension, Path, State},
http::StatusCode, http::StatusCode,
response::Response, response::Response,
}; };
@@ -9,15 +9,22 @@ use module_runtime::{
RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord,
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileWalletLedgerSourceType, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord,
RuntimeReferralInviteCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle,
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
use shared_contracts::runtime::{ use shared_contracts::runtime::{
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileInviteCodeRequest, AdminDisableProfileRedeemCodeRequest, AdminDisableProfileTaskConfigRequest,
AdminUpsertProfileRedeemCodeRequest, CreateProfileRechargeOrderRequest, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRedeemCodeRequest,
CreateProfileRechargeOrderResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, AdminUpsertProfileTaskConfigRequest, ClaimProfileTaskRewardResponse,
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED,
PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD,
@@ -25,13 +32,17 @@ use shared_contracts::runtime::{
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM, PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse,
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
ProfileReferralInviteCenterResponse, ProfileReferralInvitedUserResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse,
ProfileRedeemCodeAdminResponse, ProfileReferralInviteCenterResponse,
ProfileReferralInvitedUserResponse, ProfileTaskCenterResponse,
ProfileTaskConfigAdminListResponse, ProfileTaskConfigAdminResponse, ProfileTaskItemResponse,
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse, RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, TRACKING_SCOPE_KIND_MODULE,
TRACKING_SCOPE_KIND_SITE, TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK,
}; };
use spacetime_client::SpacetimeClientError; use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -91,14 +102,7 @@ pub async fn get_profile_wallet_ledger(
ProfileWalletLedgerResponse { ProfileWalletLedgerResponse {
entries: entries entries: entries
.into_iter() .into_iter()
.map(|entry| ProfileWalletLedgerEntryResponse { .map(build_profile_wallet_ledger_entry_response)
id: entry.wallet_ledger_id,
amount_delta: entry.amount_delta,
balance_after: entry.balance_after,
source_type: format_profile_wallet_ledger_source_type(entry.source_type)
.to_string(),
created_at: entry.created_at,
})
.collect(), .collect(),
}, },
)) ))
@@ -135,6 +139,9 @@ fn format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => { RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
} }
RuntimeProfileWalletLedgerSourceType::DailyTaskReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD
}
} }
} }
@@ -270,6 +277,184 @@ pub async fn redeem_profile_reward_code(
)) ))
} }
pub async fn get_profile_task_center(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_profile_task_center(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_task_center_response(record),
))
}
pub async fn claim_profile_task_reward(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(task_id): Path<String>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.claim_profile_task_reward(user_id, task_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_claim_profile_task_reward_response(record),
))
}
pub async fn admin_list_profile_task_configs(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.admin_list_profile_task_configs(admin.session().subject.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileTaskConfigAdminListResponse {
entries: entries
.into_iter()
.map(build_profile_task_config_admin_response)
.collect(),
},
))
}
pub async fn admin_upsert_profile_task_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertProfileTaskConfigRequest>,
) -> Result<Json<Value>, Response> {
let cycle = parse_profile_task_cycle(&payload.cycle).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let scope_kind = parse_tracking_scope_kind(&payload.scope_kind).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.admin_upsert_profile_task_config(
admin.session().subject.clone(),
payload.task_id,
payload.title,
payload.description.unwrap_or_default(),
payload.event_key,
cycle,
scope_kind,
payload.threshold,
payload.reward_points,
payload.enabled,
payload.sort_order.unwrap_or(10),
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_task_config_admin_response(record),
))
}
pub async fn admin_disable_profile_task_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminDisableProfileTaskConfigRequest>,
) -> Result<Json<Value>, Response> {
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.admin_disable_profile_task_config(
admin.session().subject.clone(),
payload.task_id,
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_task_config_admin_response(record),
))
}
pub async fn admin_list_profile_redeem_codes(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.admin_list_profile_redeem_codes(admin.session().subject.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileRedeemCodeAdminListResponse {
entries: entries
.into_iter()
.map(build_profile_redeem_code_admin_response)
.collect(),
},
))
}
pub async fn admin_upsert_profile_redeem_code( pub async fn admin_upsert_profile_redeem_code(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -338,6 +523,33 @@ pub async fn admin_disable_profile_redeem_code(
)) ))
} }
pub async fn admin_list_profile_invite_codes(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.admin_list_profile_invite_codes(admin.session().subject.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileInviteCodeAdminListResponse {
entries: entries
.into_iter()
.map(build_profile_invite_code_admin_response)
.collect(),
},
))
}
pub async fn admin_upsert_profile_invite_code( pub async fn admin_upsert_profile_invite_code(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -553,14 +765,87 @@ fn build_redeem_profile_reward_code_response(
RedeemProfileRewardCodeResponse { RedeemProfileRewardCodeResponse {
wallet_balance: record.wallet_balance, wallet_balance: record.wallet_balance,
amount_granted: record.amount_granted, amount_granted: record.amount_granted,
ledger_entry: ProfileWalletLedgerEntryResponse { ledger_entry: build_profile_wallet_ledger_entry_response(record.ledger_entry),
id: record.ledger_entry.wallet_ledger_id, }
amount_delta: record.ledger_entry.amount_delta, }
balance_after: record.ledger_entry.balance_after,
source_type: format_profile_wallet_ledger_source_type(record.ledger_entry.source_type) fn build_profile_wallet_ledger_entry_response(
.to_string(), record: module_runtime::RuntimeProfileWalletLedgerEntryRecord,
created_at: record.ledger_entry.created_at, ) -> ProfileWalletLedgerEntryResponse {
}, ProfileWalletLedgerEntryResponse {
id: record.wallet_ledger_id,
amount_delta: record.amount_delta,
balance_after: record.balance_after,
source_type: format_profile_wallet_ledger_source_type(record.source_type).to_string(),
created_at: record.created_at,
}
}
fn build_profile_task_center_response(
record: RuntimeProfileTaskCenterRecord,
) -> ProfileTaskCenterResponse {
ProfileTaskCenterResponse {
day_key: record.day_key,
wallet_balance: record.wallet_balance,
tasks: record
.tasks
.into_iter()
.map(build_profile_task_item_response)
.collect(),
updated_at: record.updated_at,
}
}
fn build_profile_task_item_response(
record: RuntimeProfileTaskItemRecord,
) -> ProfileTaskItemResponse {
ProfileTaskItemResponse {
task_id: record.task_id,
title: record.title,
description: record.description,
event_key: record.event_key,
cycle: format_profile_task_cycle(record.cycle).to_string(),
threshold: record.threshold,
progress_count: record.progress_count,
reward_points: record.reward_points,
status: format_profile_task_status(record.status).to_string(),
day_key: record.day_key,
claimed_at: record.claimed_at,
updated_at: record.updated_at,
}
}
fn build_claim_profile_task_reward_response(
record: RuntimeProfileTaskClaimRecord,
) -> ClaimProfileTaskRewardResponse {
ClaimProfileTaskRewardResponse {
task_id: record.task_id,
day_key: record.day_key,
reward_points: record.reward_points,
wallet_balance: record.wallet_balance,
ledger_entry: build_profile_wallet_ledger_entry_response(record.ledger_entry),
center: build_profile_task_center_response(record.center),
}
}
fn build_profile_task_config_admin_response(
record: RuntimeProfileTaskConfigRecord,
) -> ProfileTaskConfigAdminResponse {
ProfileTaskConfigAdminResponse {
task_id: record.task_id,
title: record.title,
description: record.description,
event_key: record.event_key,
cycle: format_profile_task_cycle(record.cycle).to_string(),
scope_kind: format_tracking_scope_kind(record.scope_kind).to_string(),
threshold: record.threshold,
reward_points: record.reward_points,
enabled: record.enabled,
sort_order: record.sort_order,
created_by: record.created_by,
created_at: record.created_at,
updated_by: record.updated_by,
updated_at: record.updated_at,
} }
} }
@@ -597,6 +882,47 @@ fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeM
} }
} }
fn parse_profile_task_cycle(raw: &str) -> Result<RuntimeProfileTaskCycle, String> {
match raw.trim().to_ascii_lowercase().as_str() {
PROFILE_TASK_CYCLE_DAILY => Ok(RuntimeProfileTaskCycle::Daily),
_ => Err("任务周期无效".to_string()),
}
}
fn parse_tracking_scope_kind(raw: &str) -> Result<RuntimeTrackingScopeKind, String> {
match raw.trim().to_ascii_lowercase().as_str() {
TRACKING_SCOPE_KIND_SITE => Ok(RuntimeTrackingScopeKind::Site),
TRACKING_SCOPE_KIND_WORK => Ok(RuntimeTrackingScopeKind::Work),
TRACKING_SCOPE_KIND_MODULE => Ok(RuntimeTrackingScopeKind::Module),
TRACKING_SCOPE_KIND_USER => Ok(RuntimeTrackingScopeKind::User),
_ => Err("埋点范围无效".to_string()),
}
}
fn format_profile_task_cycle(cycle: RuntimeProfileTaskCycle) -> &'static str {
match cycle {
RuntimeProfileTaskCycle::Daily => PROFILE_TASK_CYCLE_DAILY,
}
}
fn format_profile_task_status(status: RuntimeProfileTaskStatus) -> &'static str {
match status {
RuntimeProfileTaskStatus::Incomplete => PROFILE_TASK_STATUS_INCOMPLETE,
RuntimeProfileTaskStatus::Claimable => PROFILE_TASK_STATUS_CLAIMABLE,
RuntimeProfileTaskStatus::Claimed => PROFILE_TASK_STATUS_CLAIMED,
RuntimeProfileTaskStatus::Disabled => PROFILE_TASK_STATUS_DISABLED,
}
}
fn format_tracking_scope_kind(scope_kind: RuntimeTrackingScopeKind) -> &'static str {
match scope_kind {
RuntimeTrackingScopeKind::Site => TRACKING_SCOPE_KIND_SITE,
RuntimeTrackingScopeKind::Work => TRACKING_SCOPE_KIND_WORK,
RuntimeTrackingScopeKind::Module => TRACKING_SCOPE_KIND_MODULE,
RuntimeTrackingScopeKind::User => TRACKING_SCOPE_KIND_USER,
}
}
fn build_profile_invite_code_admin_response( fn build_profile_invite_code_admin_response(
record: RuntimeProfileInviteCodeRecord, record: RuntimeProfileInviteCodeRecord,
) -> ProfileInviteCodeAdminResponse { ) -> ProfileInviteCodeAdminResponse {
@@ -675,6 +1001,12 @@ mod tests {
), ),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
); );
assert_eq!(
format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::DailyTaskReward
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD
);
} }
#[tokio::test] #[tokio::test]
@@ -713,6 +1045,36 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[tokio::test]
async fn profile_tasks_require_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let list_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/tasks")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let claim_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/tasks/daily_login/claim")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(list_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(claim_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test] #[tokio::test]
async fn profile_play_stats_requires_authentication() { async fn profile_play_stats_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -892,6 +1254,78 @@ mod tests {
} }
} }
#[tokio::test]
async fn admin_profile_task_routes_require_admin_authentication() {
let app = build_router(
AppState::new(admin_enabled_test_config()).expect("state should build"),
);
let list_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/admin/api/profile/tasks")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let upsert_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/profile/tasks")
.header("content-type", "application/json")
.body(Body::from(r#"{"taskId":"daily_login"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
let disable_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/profile/tasks/disable")
.header("content-type", "application/json")
.body(Body::from(r#"{"taskId":"daily_login"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(list_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(upsert_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(disable_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn admin_profile_code_list_routes_require_admin_authentication() {
let app = build_router(
AppState::new(admin_enabled_test_config()).expect("state should build"),
);
for uri in [
"/admin/api/profile/redeem-codes",
"/admin/api/profile/invite-codes",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{uri}");
}
}
async fn seed_authenticated_state() -> AppState { async fn seed_authenticated_state() -> AppState {
let state = AppState::new(fast_spacetime_timeout_config()).expect("state should build"); let state = AppState::new(fast_spacetime_timeout_config()).expect("state should build");
state state
@@ -908,6 +1342,14 @@ mod tests {
} }
} }
fn admin_enabled_test_config() -> AppConfig {
AppConfig {
admin_username: Some("root".to_string()),
admin_password: Some("secret123".to_string()),
..AppConfig::default()
}
}
fn issue_access_token(state: &AppState) -> String { fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input( let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput { AccessTokenClaimsInput {

View File

@@ -2,15 +2,56 @@ use shared_kernel::{normalize_optional_string, normalize_required_string, normal
use crate::commands::{default_tags_for_theme, validate_result_publish_fields}; use crate::commands::{default_tags_for_theme, validate_result_publish_fields};
use crate::{ use crate::{
MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS, MATCH3D_BOARD_SAFE_MARGIN, MATCH3D_BLOCK_VISUAL_KEYS, MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS,
MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_FRUIT_VISUAL_KEYS, MATCH3D_ITEMS_PER_CLEAR, MATCH3D_BOARD_SAFE_MARGIN, MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_ITEMS_PER_CLEAR,
MATCH3D_MAX_DIFFICULTY, MATCH3D_MIN_DIFFICULTY, MATCH3D_SHAPE_VISUAL_KEYS, MATCH3D_MAX_DIFFICULTY, MATCH3D_MAX_ITEM_TYPE_COUNT, MATCH3D_MIN_DIFFICULTY,
MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput, Match3DClickRejectReason, MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput,
Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError, Match3DItemSnapshot, Match3DClickRejectReason, Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError,
Match3DItemState, Match3DPublicationStatus, Match3DResultDraft, Match3DRunSnapshot, Match3DItemSnapshot, Match3DItemState, Match3DPublicationStatus, Match3DResultDraft,
Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile, Match3DRunSnapshot, Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile,
}; };
#[derive(Clone, Copy)]
struct Match3DSizeTierRule {
ratio: f32,
radius_scale: f32,
relative_volume: f32,
tier: &'static str,
}
const MATCH3D_SIZE_TIER_RULES: [Match3DSizeTierRule; 5] = [
Match3DSizeTierRule {
tier: "XL",
ratio: 0.20,
relative_volume: 1.86,
radius_scale: 1.23,
},
Match3DSizeTierRule {
tier: "L",
ratio: 0.30,
relative_volume: 1.40,
radius_scale: 1.12,
},
Match3DSizeTierRule {
tier: "M",
ratio: 0.30,
relative_volume: 1.00,
radius_scale: 1.00,
},
Match3DSizeTierRule {
tier: "XS",
ratio: 0.15,
relative_volume: 0.73,
radius_scale: 0.90,
},
Match3DSizeTierRule {
tier: "S",
ratio: 0.05,
relative_volume: 0.44,
radius_scale: 0.76,
},
];
pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft { pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft {
let game_name = format!("{}抓大鹅", config.theme_text); let game_name = format!("{}抓大鹅", config.theme_text);
let summary = format!( let summary = format!(
@@ -268,17 +309,18 @@ fn build_initial_items(
) -> Vec<Match3DItemSnapshot> { ) -> Vec<Match3DItemSnapshot> {
let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64); let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64);
let base_radius = resolve_item_radius(difficulty); let base_radius = resolve_item_radius(difficulty);
let visual_keys = visual_keys_for_theme(theme_text); let selected_visual_keys = select_visual_keys(&mut rng, theme_text, clear_count);
let item_type_count = resolve_item_type_count(clear_count);
let size_tier_plan = resolve_size_tier_plan(item_type_count);
let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize); let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize);
for clear_index in 0..clear_count { for clear_index in 0..clear_count {
let visual_index = (clear_index as usize) % visual_keys.len(); let visual_index = (clear_index as usize) % item_type_count;
let item_type_id = format!("match3d-type-{:02}", visual_index + 1); let item_type_id = format!("match3d-type-{:02}", visual_index + 1);
let visual_key = visual_keys[visual_index].to_string(); let visual_key = selected_visual_keys[visual_index].to_string();
let radius = resolve_item_radius_variant(base_radius, size_tier_plan[visual_index]);
for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR { for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR {
let radius =
resolve_item_radius_variant(base_radius, &visual_key, visual_index, copy_index);
let (x, y) = random_point_in_circle(&mut rng, max_spawn_offset(radius)); let (x, y) = random_point_in_circle(&mut rng, max_spawn_offset(radius));
let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index; let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index;
items.push(Match3DItemSnapshot { items.push(Match3DItemSnapshot {
@@ -308,22 +350,57 @@ fn build_initial_items(
items items
} }
fn visual_keys_for_theme(theme_text: &str) -> &'static [&'static str; 10] { fn resolve_size_tier_plan(item_type_count: usize) -> Vec<Match3DSizeTierRule> {
if is_fruit_theme(theme_text) { let mut plans = MATCH3D_SIZE_TIER_RULES
&MATCH3D_FRUIT_VISUAL_KEYS .iter()
} else { .map(|rule| {
&MATCH3D_SHAPE_VISUAL_KEYS let exact_count = item_type_count as f32 * rule.ratio;
(exact_count.floor() as usize, exact_count.fract(), *rule)
})
.collect::<Vec<_>>();
let mut assigned_count = plans
.iter()
.map(|(count, _, _)| *count)
.sum::<usize>();
let mut remainder_order = (0..plans.len()).collect::<Vec<_>>();
remainder_order.sort_by(|left, right| {
plans[*right]
.1
.partial_cmp(&plans[*left].1)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut cursor = 0;
while assigned_count < item_type_count {
let plan_index = remainder_order[cursor % remainder_order.len()];
plans[plan_index].0 += 1;
assigned_count += 1;
cursor += 1;
} }
plans
.into_iter()
.flat_map(|(count, _, rule)| std::iter::repeat(rule).take(count))
.collect()
} }
fn is_fruit_theme(theme_text: &str) -> bool { fn resolve_item_type_count(clear_count: u32) -> usize {
let normalized = theme_text.trim().to_lowercase(); clear_count.clamp(1, MATCH3D_MAX_ITEM_TYPE_COUNT) as usize
[ }
"水果", "果蔬", "果物", "fruit", "fruits", "苹果", "香蕉", "葡萄", "西瓜", "草莓", "",
"", "", "", "", fn select_visual_keys(
] rng: &mut DeterministicRng,
.iter() _theme_text: &str,
.any(|marker| normalized.contains(marker)) clear_count: u32,
) -> Vec<&'static str> {
let item_type_count = resolve_item_type_count(clear_count);
let mut visual_keys = MATCH3D_BLOCK_VISUAL_KEYS.to_vec();
// 中文注释:只打乱类型池顺序,不改变每个类型三件一组的可通关结构。
for index in (1..visual_keys.len()).rev() {
let swap_index = (rng.next_u32() as usize) % (index + 1);
visual_keys.swap(index, swap_index);
}
visual_keys.truncate(item_type_count);
visual_keys
} }
fn resolve_item_radius(difficulty: u32) -> f32 { fn resolve_item_radius(difficulty: u32) -> f32 {
@@ -332,48 +409,10 @@ fn resolve_item_radius(difficulty: u32) -> f32 {
radius.max(0.052) radius.max(0.052)
} }
fn resolve_item_radius_variant( fn resolve_item_radius_variant(base_radius: f32, size_tier: Match3DSizeTierRule) -> f32 {
base_radius: f32, debug_assert!(!size_tier.tier.is_empty());
visual_key: &str, debug_assert!(size_tier.relative_volume > 0.0);
visual_index: usize, (base_radius * size_tier.radius_scale).clamp(0.045, 0.13)
copy_index: u32,
) -> f32 {
let copy_delta = (copy_index as f32 - 1.0) * 0.002;
if is_fruit_visual_key(visual_key) {
return (base_radius * fruit_visual_size_scale(visual_key) + copy_delta).clamp(0.04, 0.13);
}
let type_delta = ((visual_index % 5) as f32 - 2.0) * 0.004;
(base_radius + type_delta + copy_delta).clamp(0.045, 0.12)
}
fn is_fruit_visual_key(visual_key: &str) -> bool {
matches!(
visual_key,
"watermelon-green"
| "apple-red"
| "banana-yellow"
| "grape-purple"
| "melon-green"
| "berry-blue"
| "peach-pink"
| "plum-indigo"
| "lime-lime"
| "orange-orange"
| "pear-cyan"
)
}
fn fruit_visual_size_scale(visual_key: &str) -> f32 {
match visual_key {
"watermelon-green" => 1.24,
"melon-green" => 1.12,
"banana-yellow" => 1.04,
"apple-red" | "orange-orange" | "peach-pink" | "pear-cyan" => 1.0,
"plum-indigo" | "lime-lime" => 0.86,
"grape-purple" | "berry-blue" => 0.78,
_ => 1.0,
}
} }
fn max_spawn_offset(radius: f32) -> f32 { fn max_spawn_offset(radius: f32) -> f32 {
@@ -623,6 +662,79 @@ mod tests {
assert!(counts.values().all(|count| count % 3 == 0)); assert!(counts.values().all(|count| count % 3 == 0));
} }
#[test]
fn item_type_count_follows_clear_count_until_twenty_five() {
let run = start_run_with_seed_at(
"run-types-small".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(12),
42,
1_000,
)
.expect("run should start");
let mut counts = BTreeMap::<String, u32>::new();
for item in &run.items {
*counts.entry(item.item_type_id.clone()).or_default() += 1;
}
assert_eq!(counts.len(), 12);
assert!(counts.values().all(|count| *count == 3));
}
#[test]
fn visual_key_count_follows_fifteen_clear_count() {
let run = start_run_with_seed_at(
"run-types-fifteen".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(15),
42,
1_000,
)
.expect("run should start");
let mut counts = BTreeMap::<String, u32>::new();
let mut item_types_by_visual_key = BTreeMap::<String, Vec<String>>::new();
for item in &run.items {
*counts.entry(item.visual_key.clone()).or_default() += 1;
item_types_by_visual_key
.entry(item.visual_key.clone())
.or_default()
.push(item.item_type_id.clone());
}
assert_eq!(counts.len(), 15);
assert!(counts.values().all(|count| *count == 3));
assert!(item_types_by_visual_key.values().all(|item_type_ids| {
item_type_ids
.iter()
.all(|item_type_id| item_type_id == &item_type_ids[0])
}));
}
#[test]
fn item_type_count_is_capped_at_twenty_five() {
let run = start_run_with_seed_at(
"run-types-large".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(100),
42,
1_000,
)
.expect("run should start");
let mut counts = BTreeMap::<String, u32>::new();
for item in &run.items {
*counts.entry(item.item_type_id.clone()).or_default() += 1;
}
assert_eq!(counts.len(), 25);
assert!(counts.values().all(|count| count % 3 == 0));
}
#[test] #[test]
fn initial_run_uses_slightly_different_item_sizes() { fn initial_run_uses_slightly_different_item_sizes() {
let run = start_run_with_seed_at( let run = start_run_with_seed_at(
@@ -647,9 +759,58 @@ mod tests {
} }
#[test] #[test]
fn fruit_theme_generates_fruit_visuals_inside_board() { fn size_tier_plan_follows_ratio_for_twenty_five_types() {
let plan = resolve_size_tier_plan(25);
let mut counts = BTreeMap::<&str, usize>::new();
for rule in plan {
*counts.entry(rule.tier).or_default() += 1;
match rule.tier {
"XL" => assert!((1.60..=2.30).contains(&rule.relative_volume)),
"L" => assert!((1.25..=1.60).contains(&rule.relative_volume)),
"M" => assert_eq!(rule.relative_volume, 1.00),
"XS" => assert!((0.65..=0.85).contains(&rule.relative_volume)),
"S" => assert!((0.35..=0.50).contains(&rule.relative_volume)),
_ => panic!("unknown size tier"),
}
}
assert_eq!(counts.get("XL"), Some(&5));
assert_eq!(counts.get("L"), Some(&8));
assert_eq!(counts.get("M"), Some(&7));
assert_eq!(counts.get("XS"), Some(&4));
assert_eq!(counts.get("S"), Some(&1));
}
#[test]
fn same_visual_key_keeps_one_size_in_run() {
let run = start_run_with_seed_at( let run = start_run_with_seed_at(
"run-fruit".to_string(), "run-size-unique".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(30),
42,
1_000,
)
.expect("run should start");
let mut radii_by_visual_key = BTreeMap::<String, Vec<u32>>::new();
for item in &run.items {
radii_by_visual_key
.entry(item.visual_key.clone())
.or_default()
.push((item.radius * 10_000.0).round() as u32);
}
assert_eq!(radii_by_visual_key.len(), 25);
assert!(radii_by_visual_key.values().all(|radii| {
radii.iter().all(|radius| radius == &radii[0])
}));
}
#[test]
fn block_visuals_stay_inside_board() {
let run = start_run_with_seed_at(
"run-blocks".to_string(),
"user-1".to_string(), "user-1".to_string(),
"profile-1".to_string(), "profile-1".to_string(),
&test_config(10), &test_config(10),
@@ -663,10 +824,7 @@ mod tests {
.iter() .iter()
.map(|item| item.visual_key.as_str()) .map(|item| item.visual_key.as_str())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert!(visual_keys.contains(&"watermelon-green")); assert!(visual_keys.iter().all(|visual_key| visual_key.starts_with("block-")));
assert!(visual_keys.contains(&"apple-red"));
assert!(visual_keys.contains(&"banana-yellow"));
assert!(!visual_keys.contains(&"red_circle"));
for item in &run.items { for item in &run.items {
let dx = item.x - MATCH3D_BOARD_CENTER; let dx = item.x - MATCH3D_BOARD_CENTER;
@@ -684,38 +842,31 @@ mod tests {
} }
#[test] #[test]
fn fruit_theme_uses_common_sense_relative_sizes() { fn twenty_five_or_less_does_not_repeat_visual_keys() {
let run = start_run_with_seed_at( let run = start_run_with_seed_at(
"run-fruit-size".to_string(), "run-block-unique".to_string(),
"user-1".to_string(), "user-1".to_string(),
"profile-1".to_string(), "profile-1".to_string(),
&test_config(10), &test_config(25),
27, 27,
1_000, 1_000,
) )
.expect("run should start"); .expect("run should start");
let max_radius_for_visual = |visual_key: &str| { let mut counts = BTreeMap::<String, u32>::new();
run.items for item in &run.items {
.iter() *counts.entry(item.visual_key.clone()).or_default() += 1;
.filter(|item| item.visual_key == visual_key) }
.map(|item| item.radius)
.fold(0.0, f32::max)
};
let watermelon = max_radius_for_visual("watermelon-green"); assert_eq!(counts.len(), 25);
let apple = max_radius_for_visual("apple-red"); assert!(counts.values().all(|count| *count == 3));
let grape = max_radius_for_visual("grape-purple");
assert!(watermelon > apple);
assert!(apple > grape);
} }
#[test] #[test]
fn non_fruit_theme_generates_shape_visuals() { fn block_visuals_have_different_relative_sizes() {
let config = build_creator_config("玩具", None, 3, 4).expect("config should be valid"); let config = build_creator_config("玩具", None, 3, 4).expect("config should be valid");
let run = start_run_with_seed_at( let run = start_run_with_seed_at(
"run-shapes".to_string(), "run-block-size".to_string(),
"user-1".to_string(), "user-1".to_string(),
"profile-1".to_string(), "profile-1".to_string(),
&config, &config,
@@ -724,14 +875,15 @@ mod tests {
) )
.expect("run should start"); .expect("run should start");
let visual_keys = run let mut radii = run
.items .items
.iter() .iter()
.map(|item| item.visual_key.as_str()) .map(|item| (item.radius * 1_000.0).round() as u32)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert!(visual_keys.contains(&"red_circle")); radii.sort();
assert!(visual_keys.contains(&"yellow_triangle")); radii.dedup();
assert!(!visual_keys.contains(&"apple-red"));
assert!(radii.len() > 1);
} }
#[test] #[test]

View File

@@ -9,6 +9,8 @@ pub const MATCH3D_WORK_ID_PREFIX: &str = "match3d-work-";
pub const MATCH3D_RUN_ID_PREFIX: &str = "match3d-run-"; pub const MATCH3D_RUN_ID_PREFIX: &str = "match3d-run-";
pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7; pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7;
pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3; pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3;
pub const MATCH3D_MAX_ITEM_TYPE_COUNT: u32 = 25;
pub(crate) const MATCH3D_MAX_ITEM_TYPE_COUNT_USIZE: usize = 25;
pub const MATCH3D_MIN_DIFFICULTY: u32 = 1; pub const MATCH3D_MIN_DIFFICULTY: u32 = 1;
pub const MATCH3D_MAX_DIFFICULTY: u32 = 10; pub const MATCH3D_MAX_DIFFICULTY: u32 = 10;
pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: u64 = 10 * 60 * 1000; pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: u64 = 10 * 60 * 1000;
@@ -16,32 +18,34 @@ pub const MATCH3D_BOARD_CENTER: f32 = 0.5;
pub const MATCH3D_BOARD_RADIUS: f32 = 0.5; pub const MATCH3D_BOARD_RADIUS: f32 = 0.5;
pub const MATCH3D_BOARD_SAFE_MARGIN: f32 = 0.035; pub const MATCH3D_BOARD_SAFE_MARGIN: f32 = 0.035;
// 中文注释:首版 demo 不接真实图片生成,但水果题材必须先给出可辨认的水果内置视觉键 // 中文注释:首版 demo 不接真实图片生成,当前先用程序化积木件作为稳定可辨认的默认素材
pub(crate) const MATCH3D_FRUIT_VISUAL_KEYS: [&str; 10] = [ // 中文注释:当前 demo 使用 25 个积木件作为默认可消除物资源池,前端据 visual_key 程序化生成 3D 模型。
"watermelon-green", pub(crate) const MATCH3D_BLOCK_VISUAL_KEYS: [&str; MATCH3D_MAX_ITEM_TYPE_COUNT_USIZE] = [
"apple-red", "block-red-2x4",
"banana-yellow", "block-blue-1x2",
"grape-purple", "block-yellow-2x2",
"melon-green", "block-green-1x4",
"berry-blue", "block-orange-1x6",
"peach-pink", "block-white-1x1",
"plum-indigo", "block-black-1x8",
"lime-lime", "block-tan-2x3",
"orange-orange", "block-lime-1x2",
]; "block-darkred-2x2",
"block-blue-1x4",
// 中文注释:非水果题材使用颜色形状兜底 key前端必须逐个渲染不能统一兜成同一图案。 "block-pink-2x4",
pub(crate) const MATCH3D_SHAPE_VISUAL_KEYS: [&str; 10] = [ "block-gray-1x6",
"red_circle", "block-lavender-tile-2x2",
"yellow_triangle", "block-teal-tile-1x3",
"purple_diamond", "block-mint-tile-1x4",
"green_square", "block-magenta-tile-2x2",
"blue_star", "block-orange-tile-2x2-stud",
"orange_hexagon", "block-purple-slope-1x2",
"cyan_capsule", "block-brown-slope-1x2",
"pink_heart", "block-sky-slope-2x2",
"lime_leaf", "block-green-cylinder",
"white_moon", "block-clear-ring",
"block-mint-arch",
"block-gold-cone",
]; ];
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]

View File

@@ -217,6 +217,183 @@ pub fn build_runtime_profile_reward_code_redeem_record(
} }
} }
pub fn runtime_profile_beijing_day_key(now_micros: i64) -> i64 {
now_micros
.saturating_add(PROFILE_TASK_BEIJING_OFFSET_MICROS)
.div_euclid(PROFILE_RUNTIME_DAY_MICROS)
}
pub fn build_default_runtime_profile_task_config(
updated_at_micros: i64,
updated_by: String,
) -> RuntimeProfileTaskConfigSnapshot {
RuntimeProfileTaskConfigSnapshot {
task_id: PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
title: PROFILE_TASK_DEFAULT_TITLE_DAILY_LOGIN.to_string(),
description: String::new(),
event_key: PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
cycle: RuntimeProfileTaskCycle::Daily,
scope_kind: RuntimeTrackingScopeKind::User,
threshold: PROFILE_TASK_DEFAULT_THRESHOLD,
reward_points: PROFILE_TASK_DEFAULT_REWARD_POINTS,
enabled: true,
sort_order: 10,
created_by: updated_by.clone(),
created_at_micros: updated_at_micros,
updated_by,
updated_at_micros,
}
}
pub fn resolve_runtime_profile_task_status(
enabled: bool,
progress_count: u32,
threshold: u32,
claimed: bool,
) -> RuntimeProfileTaskStatus {
if !enabled {
return RuntimeProfileTaskStatus::Disabled;
}
if claimed {
return RuntimeProfileTaskStatus::Claimed;
}
if progress_count >= threshold {
RuntimeProfileTaskStatus::Claimable
} else {
RuntimeProfileTaskStatus::Incomplete
}
}
pub fn build_runtime_profile_task_progress_id(
user_id: &str,
task_id: &str,
day_key: i64,
) -> String {
format!("{}:{}:{}", user_id.trim(), task_id.trim(), day_key)
}
pub fn build_runtime_profile_task_claim_id(user_id: &str, task_id: &str, day_key: i64) -> String {
build_runtime_profile_task_progress_id(user_id, task_id, day_key)
}
pub fn build_runtime_profile_task_reward_ledger_id(
user_id: &str,
task_id: &str,
day_key: i64,
) -> String {
format!(
"task-reward:{}:{}:{}",
user_id.trim(),
task_id.trim(),
day_key
)
}
pub fn build_runtime_tracking_event_id(
event_key: &str,
scope_kind: RuntimeTrackingScopeKind,
scope_id: &str,
occurred_at_micros: i64,
) -> String {
format!(
"tracking:{}:{}:{}:{}",
event_key.trim(),
scope_kind.as_str(),
scope_id.trim(),
occurred_at_micros
)
}
pub fn build_runtime_tracking_daily_stat_id(
event_key: &str,
scope_kind: RuntimeTrackingScopeKind,
scope_id: &str,
day_key: i64,
) -> String {
format!(
"tracking-stat:{}:{}:{}:{}",
event_key.trim(),
scope_kind.as_str(),
scope_id.trim(),
day_key
)
}
pub fn build_runtime_profile_task_config_record(
snapshot: RuntimeProfileTaskConfigSnapshot,
) -> RuntimeProfileTaskConfigRecord {
RuntimeProfileTaskConfigRecord {
task_id: snapshot.task_id,
title: snapshot.title,
description: snapshot.description,
event_key: snapshot.event_key,
cycle: snapshot.cycle,
scope_kind: snapshot.scope_kind,
threshold: snapshot.threshold,
reward_points: snapshot.reward_points,
enabled: snapshot.enabled,
sort_order: snapshot.sort_order,
created_by: snapshot.created_by,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
updated_by: snapshot.updated_by,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_task_item_record(
snapshot: RuntimeProfileTaskItemSnapshot,
) -> RuntimeProfileTaskItemRecord {
RuntimeProfileTaskItemRecord {
task_id: snapshot.task_id,
title: snapshot.title,
description: snapshot.description,
event_key: snapshot.event_key,
cycle: snapshot.cycle,
threshold: snapshot.threshold,
progress_count: snapshot.progress_count,
reward_points: snapshot.reward_points,
status: snapshot.status,
day_key: snapshot.day_key,
claimed_at: snapshot.claimed_at_micros.map(format_utc_micros),
claimed_at_micros: snapshot.claimed_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_task_center_record(
snapshot: RuntimeProfileTaskCenterSnapshot,
) -> RuntimeProfileTaskCenterRecord {
RuntimeProfileTaskCenterRecord {
user_id: snapshot.user_id,
day_key: snapshot.day_key,
wallet_balance: snapshot.wallet_balance,
tasks: snapshot
.tasks
.into_iter()
.map(build_runtime_profile_task_item_record)
.collect(),
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_task_claim_record(
snapshot: RuntimeProfileTaskClaimSnapshot,
) -> RuntimeProfileTaskClaimRecord {
RuntimeProfileTaskClaimRecord {
user_id: snapshot.user_id,
task_id: snapshot.task_id,
day_key: snapshot.day_key,
reward_points: snapshot.reward_points,
wallet_balance: snapshot.wallet_balance,
ledger_entry: build_runtime_profile_wallet_ledger_entry_record(snapshot.ledger_entry),
center: build_runtime_profile_task_center_record(snapshot.center),
}
}
pub fn build_runtime_profile_redeem_code_record( pub fn build_runtime_profile_redeem_code_record(
snapshot: RuntimeProfileRedeemCodeSnapshot, snapshot: RuntimeProfileRedeemCodeSnapshot,
) -> RuntimeProfileRedeemCodeRecord { ) -> RuntimeProfileRedeemCodeRecord {

View File

@@ -75,6 +75,121 @@ pub fn build_runtime_profile_wallet_ledger_list_input(
Ok(RuntimeProfileWalletLedgerListInput { user_id }) Ok(RuntimeProfileWalletLedgerListInput { user_id })
} }
pub fn build_runtime_tracking_event_input(
event_id: String,
event_key: String,
scope_kind: RuntimeTrackingScopeKind,
scope_id: String,
user_id: Option<String>,
owner_user_id: Option<String>,
profile_id: Option<String>,
module_key: Option<String>,
metadata_json: String,
occurred_at_micros: i64,
) -> Result<RuntimeTrackingEventInput, RuntimeProfileFieldError> {
let event_id = normalize_required_string(event_id)
.ok_or(RuntimeProfileFieldError::MissingTrackingEventId)?;
let event_key =
normalize_required_string(event_key).ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
let scope_id = normalize_required_string(scope_id)
.ok_or(RuntimeProfileFieldError::MissingTrackingScopeId)?;
let metadata_json = normalize_tracking_metadata_json(metadata_json)?;
Ok(RuntimeTrackingEventInput {
event_id,
event_key,
scope_kind,
scope_id,
user_id: normalize_optional_string(user_id),
owner_user_id: normalize_optional_string(owner_user_id),
profile_id: normalize_optional_string(profile_id),
module_key: normalize_optional_string(module_key),
metadata_json,
occurred_at_micros,
})
}
pub fn build_runtime_profile_task_center_get_input(
user_id: String,
) -> Result<RuntimeProfileTaskCenterGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeProfileTaskCenterGetInput { user_id })
}
pub fn build_runtime_profile_task_claim_input(
user_id: String,
task_id: String,
) -> Result<RuntimeProfileTaskClaimInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let task_id = normalize_profile_task_id(task_id)?;
Ok(RuntimeProfileTaskClaimInput { user_id, task_id })
}
pub fn build_runtime_profile_task_config_admin_list_input(
admin_user_id: String,
) -> Result<RuntimeProfileTaskConfigAdminListInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
Ok(RuntimeProfileTaskConfigAdminListInput { admin_user_id })
}
#[allow(clippy::too_many_arguments)]
pub fn build_runtime_profile_task_config_admin_upsert_input(
admin_user_id: String,
task_id: String,
title: String,
description: String,
event_key: String,
cycle: RuntimeProfileTaskCycle,
scope_kind: RuntimeTrackingScopeKind,
threshold: u32,
reward_points: u64,
enabled: bool,
sort_order: i32,
updated_at_micros: i64,
) -> Result<RuntimeProfileTaskConfigAdminUpsertInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let task_id = normalize_profile_task_id(task_id)?;
let title =
normalize_required_string(title).ok_or(RuntimeProfileFieldError::MissingTaskTitle)?;
let event_key =
normalize_required_string(event_key).ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
if threshold == 0 {
return Err(RuntimeProfileFieldError::InvalidTaskThreshold);
}
if reward_points == 0 || reward_points > i64::MAX as u64 {
return Err(RuntimeProfileFieldError::InvalidTaskReward);
}
Ok(RuntimeProfileTaskConfigAdminUpsertInput {
admin_user_id,
task_id,
title,
description: normalize_optional_string(Some(description)).unwrap_or_default(),
event_key,
cycle,
scope_kind,
threshold,
reward_points,
enabled,
sort_order,
updated_at_micros,
})
}
pub fn build_runtime_profile_task_config_admin_disable_input(
admin_user_id: String,
task_id: String,
updated_at_micros: i64,
) -> Result<RuntimeProfileTaskConfigAdminDisableInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let task_id = normalize_profile_task_id(task_id)?;
Ok(RuntimeProfileTaskConfigAdminDisableInput {
admin_user_id,
task_id,
updated_at_micros,
})
}
pub fn build_runtime_profile_wallet_adjustment_input( pub fn build_runtime_profile_wallet_adjustment_input(
user_id: String, user_id: String,
amount: u64, amount: u64,
@@ -200,6 +315,13 @@ pub fn build_runtime_profile_redeem_code_admin_upsert_input(
}) })
} }
pub fn build_runtime_profile_redeem_code_admin_list_input(
admin_user_id: String,
) -> Result<RuntimeProfileRedeemCodeAdminListInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
Ok(RuntimeProfileRedeemCodeAdminListInput { admin_user_id })
}
pub fn build_runtime_profile_invite_code_admin_upsert_input( pub fn build_runtime_profile_invite_code_admin_upsert_input(
admin_user_id: String, admin_user_id: String,
invite_code: String, invite_code: String,
@@ -219,6 +341,13 @@ pub fn build_runtime_profile_invite_code_admin_upsert_input(
}) })
} }
pub fn build_runtime_profile_invite_code_admin_list_input(
admin_user_id: String,
) -> Result<RuntimeProfileInviteCodeAdminListInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
Ok(RuntimeProfileInviteCodeAdminListInput { admin_user_id })
}
pub fn build_runtime_profile_redeem_code_admin_disable_input( pub fn build_runtime_profile_redeem_code_admin_disable_input(
admin_user_id: String, admin_user_id: String,
code: String, code: String,
@@ -509,3 +638,22 @@ pub fn normalize_invite_code_metadata_json(
serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata) serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)
} }
fn normalize_tracking_metadata_json(value: String) -> Result<String, RuntimeProfileFieldError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string());
}
let parsed = serde_json::from_str::<Value>(trimmed)
.map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?;
if !parsed.is_object() {
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
}
serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)
}
fn normalize_profile_task_id(value: String) -> Result<String, RuntimeProfileFieldError> {
normalize_required_string(value).ok_or(RuntimeProfileFieldError::MissingTaskId)
}

View File

@@ -20,6 +20,12 @@ pub const PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT: u32 = 10;
pub const PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON: &str = "{}"; pub const PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON: &str = "{}";
pub const PROFILE_INVITE_CODE_METADATA_MAX_BYTES: usize = 4096; pub const PROFILE_INVITE_CODE_METADATA_MAX_BYTES: usize = 4096;
pub const PROFILE_RUNTIME_DAY_MICROS: i64 = 86_400_000_000; pub const PROFILE_RUNTIME_DAY_MICROS: i64 = 86_400_000_000;
pub const PROFILE_TASK_BEIJING_OFFSET_MICROS: i64 = 28_800_000_000;
pub const PROFILE_TASK_ID_DAILY_LOGIN: &str = "daily_login";
pub const PROFILE_TASK_EVENT_KEY_DAILY_LOGIN: &str = "daily_login";
pub const PROFILE_TASK_DEFAULT_TITLE_DAILY_LOGIN: &str = "每日登录";
pub const PROFILE_TASK_DEFAULT_REWARD_POINTS: u64 = 10;
pub const PROFILE_TASK_DEFAULT_THRESHOLD: u32 = 1;
pub const SAVE_SNAPSHOT_VERSION: u32 = 2; pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。"; pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock"; pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
@@ -334,6 +340,226 @@ pub struct RuntimeProfileDashboardGetInput {
pub user_id: String, pub user_id: String,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeTrackingScopeKind {
Site,
Work,
Module,
User,
}
impl RuntimeTrackingScopeKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Site => "site",
Self::Work => "work",
Self::Module => "module",
Self::User => "user",
}
}
pub fn from_client_str(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"site" => Some(Self::Site),
"work" => Some(Self::Work),
"module" => Some(Self::Module),
"user" => Some(Self::User),
_ => None,
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileTaskCycle {
Daily,
}
impl RuntimeProfileTaskCycle {
pub fn as_str(&self) -> &'static str {
match self {
Self::Daily => "daily",
}
}
pub fn from_client_str(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"daily" => Some(Self::Daily),
_ => None,
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileTaskStatus {
Incomplete,
Claimable,
Claimed,
Disabled,
}
impl RuntimeProfileTaskStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Incomplete => "incomplete",
Self::Claimable => "claimable",
Self::Claimed => "claimed",
Self::Disabled => "disabled",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeTrackingEventInput {
pub event_id: String,
pub event_key: String,
pub scope_kind: RuntimeTrackingScopeKind,
pub scope_id: String,
pub user_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub module_key: Option<String>,
pub metadata_json: String,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskConfigSnapshot {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub scope_kind: RuntimeTrackingScopeKind,
pub threshold: u32,
pub reward_points: u64,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at_micros: i64,
pub updated_by: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskItemSnapshot {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub threshold: u32,
pub progress_count: u32,
pub reward_points: u64,
pub status: RuntimeProfileTaskStatus,
pub day_key: i64,
pub claimed_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskCenterSnapshot {
pub user_id: String,
pub day_key: i64,
pub wallet_balance: u64,
pub tasks: Vec<RuntimeProfileTaskItemSnapshot>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskCenterProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileTaskCenterSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskClaimSnapshot {
pub user_id: String,
pub task_id: String,
pub day_key: i64,
pub reward_points: u64,
pub wallet_balance: u64,
pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot,
pub center: RuntimeProfileTaskCenterSnapshot,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskClaimProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileTaskClaimSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskCenterGetInput {
pub user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskClaimInput {
pub user_id: String,
pub task_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskConfigAdminListInput {
pub admin_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskConfigAdminUpsertInput {
pub admin_user_id: String,
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub scope_kind: RuntimeTrackingScopeKind,
pub threshold: u32,
pub reward_points: u64,
pub enabled: bool,
pub sort_order: i32,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskConfigAdminDisableInput {
pub admin_user_id: String,
pub task_id: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskConfigAdminListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileTaskConfigSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskConfigAdminProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileTaskConfigSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileWalletLedgerSourceType { pub enum RuntimeProfileWalletLedgerSourceType {
@@ -346,6 +572,7 @@ pub enum RuntimeProfileWalletLedgerSourceType {
AssetOperationRefund, AssetOperationRefund,
RedeemCodeReward, RedeemCodeReward,
PuzzleAuthorIncentiveClaim, PuzzleAuthorIncentiveClaim,
DailyTaskReward,
} }
impl RuntimeProfileWalletLedgerSourceType { impl RuntimeProfileWalletLedgerSourceType {
@@ -360,6 +587,7 @@ impl RuntimeProfileWalletLedgerSourceType {
Self::AssetOperationRefund => "asset_operation_refund", Self::AssetOperationRefund => "asset_operation_refund",
Self::RedeemCodeReward => "redeem_code_reward", Self::RedeemCodeReward => "redeem_code_reward",
Self::PuzzleAuthorIncentiveClaim => "puzzle_author_incentive_claim", Self::PuzzleAuthorIncentiveClaim => "puzzle_author_incentive_claim",
Self::DailyTaskReward => "daily_task_reward",
} }
} }
} }
@@ -633,6 +861,12 @@ pub struct RuntimeProfileRedeemCodeAdminDisableInput {
pub updated_at_micros: i64, pub updated_at_micros: i64,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRedeemCodeAdminListInput {
pub admin_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRedeemCodeSnapshot { pub struct RuntimeProfileRedeemCodeSnapshot {
@@ -656,6 +890,14 @@ pub struct RuntimeProfileRedeemCodeAdminProcedureResult {
pub error_message: Option<String>, pub error_message: Option<String>,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRedeemCodeAdminListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileRedeemCodeSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileInviteCodeAdminUpsertInput { pub struct RuntimeProfileInviteCodeAdminUpsertInput {
@@ -665,6 +907,12 @@ pub struct RuntimeProfileInviteCodeAdminUpsertInput {
pub updated_at_micros: i64, pub updated_at_micros: i64,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileInviteCodeAdminListInput {
pub admin_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileInviteCodeSnapshot { pub struct RuntimeProfileInviteCodeSnapshot {
@@ -683,6 +931,14 @@ pub struct RuntimeProfileInviteCodeAdminProcedureResult {
pub error_message: Option<String>, pub error_message: Option<String>,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileInviteCodeAdminListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileInviteCodeSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralInvitedUserSnapshot { pub struct RuntimeReferralInvitedUserSnapshot {
@@ -953,6 +1209,65 @@ pub struct RuntimeProfileRewardCodeRedeemRecord {
pub ledger_entry: RuntimeProfileWalletLedgerEntryRecord, pub ledger_entry: RuntimeProfileWalletLedgerEntryRecord,
} }
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileTaskConfigRecord {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub scope_kind: RuntimeTrackingScopeKind,
pub threshold: u32,
pub reward_points: u64,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at: String,
pub created_at_micros: i64,
pub updated_by: String,
pub updated_at: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileTaskItemRecord {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub threshold: u32,
pub progress_count: u32,
pub reward_points: u64,
pub status: RuntimeProfileTaskStatus,
pub day_key: i64,
pub claimed_at: Option<String>,
pub claimed_at_micros: Option<i64>,
pub updated_at: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileTaskCenterRecord {
pub user_id: String,
pub day_key: i64,
pub wallet_balance: u64,
pub tasks: Vec<RuntimeProfileTaskItemRecord>,
pub updated_at: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileTaskClaimRecord {
pub user_id: String,
pub task_id: String,
pub day_key: i64,
pub reward_points: u64,
pub wallet_balance: u64,
pub ledger_entry: RuntimeProfileWalletLedgerEntryRecord,
pub center: RuntimeProfileTaskCenterRecord,
}
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileRedeemCodeRecord { pub struct RuntimeProfileRedeemCodeRecord {
pub code: String, pub code: String,

View File

@@ -52,6 +52,18 @@ pub enum RuntimeProfileFieldError {
InvalidRedeemCodeReward, InvalidRedeemCodeReward,
InvalidRedeemCodeMaxUses, InvalidRedeemCodeMaxUses,
InvalidInviteCodeMetadata, InvalidInviteCodeMetadata,
MissingTaskId,
MissingTaskTitle,
MissingTaskEventKey,
MissingTrackingEventId,
MissingTrackingScopeId,
InvalidTaskCycle,
InvalidTaskScopeKind,
InvalidTaskThreshold,
InvalidTaskReward,
TaskDisabled,
TaskNotClaimable,
TaskAlreadyClaimed,
MissingProductId, MissingProductId,
MissingWorldKey, MissingWorldKey,
MissingBottomTab, MissingBottomTab,
@@ -86,6 +98,18 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::InvalidInviteCodeMetadata => { Self::InvalidInviteCodeMetadata => {
f.write_str("邀请码 metadata 必须是合法 JSON object") f.write_str("邀请码 metadata 必须是合法 JSON object")
} }
Self::MissingTaskId => f.write_str("profile_task.task_id 不能为空"),
Self::MissingTaskTitle => f.write_str("profile_task.title 不能为空"),
Self::MissingTaskEventKey => f.write_str("profile_task.event_key 不能为空"),
Self::MissingTrackingEventId => f.write_str("tracking_event.event_id 不能为空"),
Self::MissingTrackingScopeId => f.write_str("tracking_event.scope_id 不能为空"),
Self::InvalidTaskCycle => f.write_str("profile_task.cycle 无效"),
Self::InvalidTaskScopeKind => f.write_str("profile_task.scope_kind 无效"),
Self::InvalidTaskThreshold => f.write_str("profile_task.threshold 必须大于 0"),
Self::InvalidTaskReward => f.write_str("profile_task.reward_points 必须大于 0"),
Self::TaskDisabled => f.write_str("任务已停用"),
Self::TaskNotClaimable => f.write_str("任务尚未达成"),
Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"),
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"), Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"), Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),

View File

@@ -448,6 +448,81 @@ mod tests {
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(), RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(),
"asset_operation_refund" "asset_operation_refund"
); );
assert_eq!(
RuntimeProfileWalletLedgerSourceType::DailyTaskReward.as_str(),
"daily_task_reward"
);
}
#[test]
fn runtime_profile_beijing_day_key_uses_business_day_boundary() {
let before_beijing_midnight = 1_714_927_999_999_999;
let after_beijing_midnight = 1_714_928_000_000_000;
assert_eq!(
runtime_profile_beijing_day_key(before_beijing_midnight),
runtime_profile_beijing_day_key(after_beijing_midnight) - 1
);
}
#[test]
fn runtime_profile_task_status_matches_progress_and_claim() {
assert_eq!(
resolve_runtime_profile_task_status(false, 1, 1, false),
RuntimeProfileTaskStatus::Disabled
);
assert_eq!(
resolve_runtime_profile_task_status(true, 0, 1, false),
RuntimeProfileTaskStatus::Incomplete
);
assert_eq!(
resolve_runtime_profile_task_status(true, 1, 1, false),
RuntimeProfileTaskStatus::Claimable
);
assert_eq!(
resolve_runtime_profile_task_status(true, 1, 1, true),
RuntimeProfileTaskStatus::Claimed
);
}
#[test]
fn build_task_config_input_rejects_invalid_reward_and_threshold() {
assert_eq!(
build_runtime_profile_task_config_admin_upsert_input(
"admin".to_string(),
PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
"每日登录".to_string(),
"".to_string(),
PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
RuntimeProfileTaskCycle::Daily,
RuntimeTrackingScopeKind::User,
0,
10,
true,
10,
1,
)
.expect_err("zero threshold should fail"),
RuntimeProfileFieldError::InvalidTaskThreshold
);
assert_eq!(
build_runtime_profile_task_config_admin_upsert_input(
"admin".to_string(),
PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
"每日登录".to_string(),
"".to_string(),
PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
RuntimeProfileTaskCycle::Daily,
RuntimeTrackingScopeKind::User,
1,
0,
true,
10,
1,
)
.expect_err("zero reward should fail"),
RuntimeProfileFieldError::InvalidTaskReward
);
} }
#[test] #[test]

View File

@@ -15,6 +15,16 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND: &str = "asse
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM: &str = pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM: &str =
"puzzle_author_incentive_claim"; "puzzle_author_incentive_claim";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD: &str = "daily_task_reward";
pub const PROFILE_TASK_CYCLE_DAILY: &str = "daily";
pub const PROFILE_TASK_STATUS_INCOMPLETE: &str = "incomplete";
pub const PROFILE_TASK_STATUS_CLAIMABLE: &str = "claimable";
pub const PROFILE_TASK_STATUS_CLAIMED: &str = "claimed";
pub const PROFILE_TASK_STATUS_DISABLED: &str = "disabled";
pub const TRACKING_SCOPE_KIND_SITE: &str = "site";
pub const TRACKING_SCOPE_KIND_WORK: &str = "work";
pub const TRACKING_SCOPE_KIND_MODULE: &str = "module";
pub const TRACKING_SCOPE_KIND_USER: &str = "user";
pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial"; pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial";
pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane"; pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane";
pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina"; pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina";
@@ -295,6 +305,92 @@ pub struct RedeemProfileRewardCodeResponse {
pub ledger_entry: ProfileWalletLedgerEntryResponse, pub ledger_entry: ProfileWalletLedgerEntryResponse,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileTaskItemResponse {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: String,
pub threshold: u32,
pub progress_count: u32,
pub reward_points: u64,
pub status: String,
pub day_key: i64,
pub claimed_at: Option<String>,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileTaskCenterResponse {
pub day_key: i64,
pub wallet_balance: u64,
pub tasks: Vec<ProfileTaskItemResponse>,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ClaimProfileTaskRewardResponse {
pub task_id: String,
pub day_key: i64,
pub reward_points: u64,
pub wallet_balance: u64,
pub ledger_entry: ProfileWalletLedgerEntryResponse,
pub center: ProfileTaskCenterResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileTaskConfigAdminResponse {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: String,
pub scope_kind: String,
pub threshold: u32,
pub reward_points: u64,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at: String,
pub updated_by: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileTaskConfigAdminListResponse {
pub entries: Vec<ProfileTaskConfigAdminResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpsertProfileTaskConfigRequest {
pub task_id: String,
pub title: String,
#[serde(default)]
pub description: Option<String>,
pub event_key: String,
pub cycle: String,
pub scope_kind: String,
pub threshold: u32,
pub reward_points: u64,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub sort_order: Option<i32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDisableProfileTaskConfigRequest {
pub task_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AdminUpsertProfileRedeemCodeRequest { pub struct AdminUpsertProfileRedeemCodeRequest {
@@ -339,6 +435,12 @@ pub struct ProfileRedeemCodeAdminResponse {
pub updated_at: String, pub updated_at: String,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileRedeemCodeAdminListResponse {
pub entries: Vec<ProfileRedeemCodeAdminResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProfileInviteCodeAdminResponse { pub struct ProfileInviteCodeAdminResponse {
@@ -349,6 +451,12 @@ pub struct ProfileInviteCodeAdminResponse {
pub updated_at: String, pub updated_at: String,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileInviteCodeAdminListResponse {
pub entries: Vec<ProfileInviteCodeAdminResponse>,
}
fn default_true() -> bool { fn default_true() -> bool {
true true
} }
@@ -958,6 +1066,13 @@ mod tests {
.to_string(), .to_string(),
created_at: "2026-04-22T10:06:00Z".to_string(), created_at: "2026-04-22T10:06:00Z".to_string(),
}, },
ProfileWalletLedgerEntryResponse {
id: "ledger-9".to_string(),
amount_delta: 10,
balance_after: 212,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD.to_string(),
created_at: "2026-04-22T10:07:00Z".to_string(),
},
], ],
}) })
.expect("payload should serialize"); .expect("payload should serialize");
@@ -996,12 +1111,66 @@ mod tests {
payload["entries"][7]["sourceType"], payload["entries"][7]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM) json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM)
); );
assert_eq!(
payload["entries"][8]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD)
);
assert_eq!( assert_eq!(
payload["entries"][0]["createdAt"], payload["entries"][0]["createdAt"],
json!("2026-04-22T09:59:00Z") json!("2026-04-22T09:59:00Z")
); );
} }
#[test]
fn profile_task_center_response_uses_camel_case_fields() {
let payload = serde_json::to_value(ProfileTaskCenterResponse {
day_key: 20576,
wallet_balance: 18,
tasks: vec![ProfileTaskItemResponse {
task_id: "daily_login".to_string(),
title: "每日登录".to_string(),
description: "".to_string(),
event_key: "daily_login".to_string(),
cycle: PROFILE_TASK_CYCLE_DAILY.to_string(),
threshold: 1,
progress_count: 1,
reward_points: 10,
status: PROFILE_TASK_STATUS_CLAIMABLE.to_string(),
day_key: 20576,
claimed_at: None,
updated_at: "2026-05-03T00:00:00Z".to_string(),
}],
updated_at: "2026-05-03T00:00:00Z".to_string(),
})
.expect("payload should serialize");
assert_eq!(payload["walletBalance"], json!(18));
assert_eq!(payload["tasks"][0]["taskId"], json!("daily_login"));
assert_eq!(payload["tasks"][0]["rewardPoints"], json!(10));
assert_eq!(
payload["tasks"][0]["status"],
json!(PROFILE_TASK_STATUS_CLAIMABLE)
);
}
#[test]
fn admin_task_config_request_accepts_defaults() {
let payload: AdminUpsertProfileTaskConfigRequest = serde_json::from_value(json!({
"taskId": "daily_login",
"title": "每日登录",
"eventKey": "daily_login",
"cycle": "daily",
"scopeKind": "user",
"threshold": 1,
"rewardPoints": 10
}))
.expect("request should deserialize");
assert_eq!(payload.description, None);
assert_eq!(payload.enabled, true);
assert_eq!(payload.sort_order, None);
}
#[test] #[test]
fn profile_recharge_center_response_uses_camel_case_fields() { fn profile_recharge_center_response_uses_camel_case_fields() {
let payload = serde_json::to_value(ProfileRechargeCenterResponse { let payload = serde_json::to_value(ProfileRechargeCenterResponse {

View File

@@ -139,21 +139,31 @@ use module_runtime::{
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord, RuntimeProfileSaveArchiveRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord,
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle as DomainRuntimeProfileTaskCycle,
RuntimeSnapshotRecord, build_runtime_browse_history_clear_input, RuntimeProfileTaskStatus as DomainRuntimeProfileTaskStatus,
build_runtime_browse_history_list_input, build_runtime_browse_history_record, RuntimeProfileWalletLedgerEntryRecord, RuntimeReferralInviteCenterRecord,
build_runtime_browse_history_sync_input, build_runtime_profile_dashboard_get_input, RuntimeReferralRedeemRecord, RuntimeSettingsRecord, RuntimeSnapshotRecord,
build_runtime_profile_dashboard_record, build_runtime_profile_invite_code_admin_upsert_input, RuntimeTrackingScopeKind as DomainRuntimeTrackingScopeKind,
build_runtime_profile_invite_code_record, build_runtime_profile_play_stats_get_input, build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input,
build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input, build_runtime_browse_history_record, build_runtime_browse_history_sync_input,
build_runtime_profile_recharge_center_record, build_runtime_profile_dashboard_get_input, build_runtime_profile_dashboard_record,
build_runtime_profile_invite_code_admin_list_input,
build_runtime_profile_invite_code_admin_upsert_input, build_runtime_profile_invite_code_record,
build_runtime_profile_play_stats_get_input, build_runtime_profile_play_stats_record,
build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record,
build_runtime_profile_recharge_order_create_input, build_runtime_profile_recharge_order_create_input,
build_runtime_profile_redeem_code_admin_disable_input, build_runtime_profile_redeem_code_admin_disable_input,
build_runtime_profile_redeem_code_admin_list_input,
build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record, build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record,
build_runtime_profile_reward_code_redeem_input, build_runtime_profile_reward_code_redeem_input,
build_runtime_profile_reward_code_redeem_record, build_runtime_profile_save_archive_list_input, build_runtime_profile_reward_code_redeem_record, build_runtime_profile_save_archive_list_input,
build_runtime_profile_save_archive_record, build_runtime_profile_save_archive_resume_input, build_runtime_profile_save_archive_record, build_runtime_profile_save_archive_resume_input,
build_runtime_profile_task_center_get_input, build_runtime_profile_task_center_record,
build_runtime_profile_task_claim_input, build_runtime_profile_task_claim_record,
build_runtime_profile_task_config_admin_disable_input,
build_runtime_profile_task_config_admin_list_input,
build_runtime_profile_task_config_admin_upsert_input, build_runtime_profile_task_config_record,
build_runtime_profile_wallet_adjustment_input, build_runtime_profile_wallet_adjustment_input,
build_runtime_profile_wallet_ledger_entry_record, build_runtime_profile_wallet_ledger_entry_record,
build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input, build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input,

View File

@@ -173,6 +173,66 @@ impl From<module_runtime::RuntimeProfileRewardCodeRedeemInput>
} }
} }
impl From<module_runtime::RuntimeProfileTaskCenterGetInput> for RuntimeProfileTaskCenterGetInput {
fn from(input: module_runtime::RuntimeProfileTaskCenterGetInput) -> Self {
Self {
user_id: input.user_id,
}
}
}
impl From<module_runtime::RuntimeProfileTaskClaimInput> for RuntimeProfileTaskClaimInput {
fn from(input: module_runtime::RuntimeProfileTaskClaimInput) -> Self {
Self {
user_id: input.user_id,
task_id: input.task_id,
}
}
}
impl From<module_runtime::RuntimeProfileTaskConfigAdminListInput>
for RuntimeProfileTaskConfigAdminListInput
{
fn from(input: module_runtime::RuntimeProfileTaskConfigAdminListInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
}
}
}
impl From<module_runtime::RuntimeProfileTaskConfigAdminUpsertInput>
for RuntimeProfileTaskConfigAdminUpsertInput
{
fn from(input: module_runtime::RuntimeProfileTaskConfigAdminUpsertInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
task_id: input.task_id,
title: input.title,
description: input.description,
event_key: input.event_key,
cycle: map_runtime_profile_task_cycle(input.cycle),
scope_kind: map_runtime_tracking_scope_kind(input.scope_kind),
threshold: input.threshold,
reward_points: input.reward_points,
enabled: input.enabled,
sort_order: input.sort_order,
updated_at_micros: input.updated_at_micros,
}
}
}
impl From<module_runtime::RuntimeProfileTaskConfigAdminDisableInput>
for RuntimeProfileTaskConfigAdminDisableInput
{
fn from(input: module_runtime::RuntimeProfileTaskConfigAdminDisableInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
task_id: input.task_id,
updated_at_micros: input.updated_at_micros,
}
}
}
impl From<module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput> impl From<module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput>
for RuntimeProfileRedeemCodeAdminUpsertInput for RuntimeProfileRedeemCodeAdminUpsertInput
{ {
@@ -203,6 +263,16 @@ impl From<module_runtime::RuntimeProfileRedeemCodeAdminDisableInput>
} }
} }
impl From<module_runtime::RuntimeProfileRedeemCodeAdminListInput>
for RuntimeProfileRedeemCodeAdminListInput
{
fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminListInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
}
}
}
impl From<module_runtime::RuntimeProfileInviteCodeAdminUpsertInput> impl From<module_runtime::RuntimeProfileInviteCodeAdminUpsertInput>
for RuntimeProfileInviteCodeAdminUpsertInput for RuntimeProfileInviteCodeAdminUpsertInput
{ {
@@ -216,6 +286,16 @@ impl From<module_runtime::RuntimeProfileInviteCodeAdminUpsertInput>
} }
} }
impl From<module_runtime::RuntimeProfileInviteCodeAdminListInput>
for RuntimeProfileInviteCodeAdminListInput
{
fn from(input: module_runtime::RuntimeProfileInviteCodeAdminListInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
}
}
}
impl From<module_runtime::RuntimeReferralInviteCenterGetInput> impl From<module_runtime::RuntimeReferralInviteCenterGetInput>
for RuntimeReferralInviteCenterGetInput for RuntimeReferralInviteCenterGetInput
{ {
@@ -801,6 +881,72 @@ pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result(
)) ))
} }
pub(crate) fn map_runtime_profile_task_center_procedure_result(
result: RuntimeProfileTaskCenterProcedureResult,
) -> Result<RuntimeProfileTaskCenterRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let snapshot = result
.record
.ok_or_else(|| SpacetimeClientError::missing_snapshot("profile task center 快照"))?;
Ok(build_runtime_profile_task_center_record(
map_runtime_profile_task_center_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_profile_task_claim_procedure_result(
result: RuntimeProfileTaskClaimProcedureResult,
) -> Result<RuntimeProfileTaskClaimRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let snapshot = result
.record
.ok_or_else(|| SpacetimeClientError::missing_snapshot("profile task claim 快照"))?;
Ok(build_runtime_profile_task_claim_record(
map_runtime_profile_task_claim_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_profile_task_config_admin_list_procedure_result(
result: RuntimeProfileTaskConfigAdminListProcedureResult,
) -> Result<Vec<RuntimeProfileTaskConfigRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(result
.entries
.into_iter()
.map(|snapshot| {
build_runtime_profile_task_config_record(map_runtime_profile_task_config_snapshot(
snapshot,
))
})
.collect())
}
pub(crate) fn map_runtime_profile_task_config_admin_procedure_result(
result: RuntimeProfileTaskConfigAdminProcedureResult,
) -> Result<RuntimeProfileTaskConfigRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let snapshot = result
.record
.ok_or_else(|| SpacetimeClientError::missing_snapshot("profile task config 快照"))?;
Ok(build_runtime_profile_task_config_record(
map_runtime_profile_task_config_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result( pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result(
result: RuntimeProfileRedeemCodeAdminProcedureResult, result: RuntimeProfileRedeemCodeAdminProcedureResult,
) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> { ) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> {
@@ -817,6 +963,24 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result(
)) ))
} }
pub(crate) fn map_runtime_profile_redeem_code_admin_list_procedure_result(
result: RuntimeProfileRedeemCodeAdminListProcedureResult,
) -> Result<Vec<RuntimeProfileRedeemCodeRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(result
.entries
.into_iter()
.map(|snapshot| {
build_runtime_profile_redeem_code_record(map_runtime_profile_redeem_code_snapshot(
snapshot,
))
})
.collect())
}
pub(crate) fn map_runtime_profile_invite_code_admin_procedure_result( pub(crate) fn map_runtime_profile_invite_code_admin_procedure_result(
result: RuntimeProfileInviteCodeAdminProcedureResult, result: RuntimeProfileInviteCodeAdminProcedureResult,
) -> Result<RuntimeProfileInviteCodeRecord, SpacetimeClientError> { ) -> Result<RuntimeProfileInviteCodeRecord, SpacetimeClientError> {
@@ -837,6 +1001,24 @@ pub(crate) fn map_runtime_profile_invite_code_admin_procedure_result(
)) ))
} }
pub(crate) fn map_runtime_profile_invite_code_admin_list_procedure_result(
result: RuntimeProfileInviteCodeAdminListProcedureResult,
) -> Result<Vec<RuntimeProfileInviteCodeRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(result
.entries
.into_iter()
.map(|snapshot| {
build_runtime_profile_invite_code_record(map_runtime_profile_invite_code_snapshot(
snapshot,
))
})
.collect())
}
pub(crate) fn map_runtime_profile_play_stats_procedure_result( pub(crate) fn map_runtime_profile_play_stats_procedure_result(
result: RuntimeProfilePlayStatsProcedureResult, result: RuntimeProfilePlayStatsProcedureResult,
) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> { ) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> {
@@ -1721,6 +1903,76 @@ pub(crate) fn map_runtime_profile_reward_code_redeem_snapshot(
} }
} }
pub(crate) fn map_runtime_profile_task_config_snapshot(
snapshot: RuntimeProfileTaskConfigSnapshot,
) -> module_runtime::RuntimeProfileTaskConfigSnapshot {
module_runtime::RuntimeProfileTaskConfigSnapshot {
task_id: snapshot.task_id,
title: snapshot.title,
description: snapshot.description,
event_key: snapshot.event_key,
cycle: map_runtime_profile_task_cycle_back(snapshot.cycle),
scope_kind: map_runtime_tracking_scope_kind_back(snapshot.scope_kind),
threshold: snapshot.threshold,
reward_points: snapshot.reward_points,
enabled: snapshot.enabled,
sort_order: snapshot.sort_order,
created_by: snapshot.created_by,
created_at_micros: snapshot.created_at_micros,
updated_by: snapshot.updated_by,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_profile_task_item_snapshot(
snapshot: RuntimeProfileTaskItemSnapshot,
) -> module_runtime::RuntimeProfileTaskItemSnapshot {
module_runtime::RuntimeProfileTaskItemSnapshot {
task_id: snapshot.task_id,
title: snapshot.title,
description: snapshot.description,
event_key: snapshot.event_key,
cycle: map_runtime_profile_task_cycle_back(snapshot.cycle),
threshold: snapshot.threshold,
progress_count: snapshot.progress_count,
reward_points: snapshot.reward_points,
status: map_runtime_profile_task_status_back(snapshot.status),
day_key: snapshot.day_key,
claimed_at_micros: snapshot.claimed_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_profile_task_center_snapshot(
snapshot: RuntimeProfileTaskCenterSnapshot,
) -> module_runtime::RuntimeProfileTaskCenterSnapshot {
module_runtime::RuntimeProfileTaskCenterSnapshot {
user_id: snapshot.user_id,
day_key: snapshot.day_key,
wallet_balance: snapshot.wallet_balance,
tasks: snapshot
.tasks
.into_iter()
.map(map_runtime_profile_task_item_snapshot)
.collect(),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_profile_task_claim_snapshot(
snapshot: RuntimeProfileTaskClaimSnapshot,
) -> module_runtime::RuntimeProfileTaskClaimSnapshot {
module_runtime::RuntimeProfileTaskClaimSnapshot {
user_id: snapshot.user_id,
task_id: snapshot.task_id,
day_key: snapshot.day_key,
reward_points: snapshot.reward_points,
wallet_balance: snapshot.wallet_balance,
ledger_entry: map_runtime_profile_wallet_ledger_entry_snapshot(snapshot.ledger_entry),
center: map_runtime_profile_task_center_snapshot(snapshot.center),
}
}
pub(crate) fn map_runtime_profile_redeem_code_snapshot( pub(crate) fn map_runtime_profile_redeem_code_snapshot(
snapshot: RuntimeProfileRedeemCodeSnapshot, snapshot: RuntimeProfileRedeemCodeSnapshot,
) -> module_runtime::RuntimeProfileRedeemCodeSnapshot { ) -> module_runtime::RuntimeProfileRedeemCodeSnapshot {
@@ -3750,6 +4002,86 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => { crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => {
module_runtime::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim module_runtime::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim
} }
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::DailyTaskReward => {
module_runtime::RuntimeProfileWalletLedgerSourceType::DailyTaskReward
}
}
}
pub(crate) fn map_runtime_tracking_scope_kind(
value: DomainRuntimeTrackingScopeKind,
) -> crate::module_bindings::RuntimeTrackingScopeKind {
match value {
DomainRuntimeTrackingScopeKind::Site => {
crate::module_bindings::RuntimeTrackingScopeKind::Site
}
DomainRuntimeTrackingScopeKind::Work => {
crate::module_bindings::RuntimeTrackingScopeKind::Work
}
DomainRuntimeTrackingScopeKind::Module => {
crate::module_bindings::RuntimeTrackingScopeKind::Module
}
DomainRuntimeTrackingScopeKind::User => {
crate::module_bindings::RuntimeTrackingScopeKind::User
}
}
}
pub(crate) fn map_runtime_tracking_scope_kind_back(
value: crate::module_bindings::RuntimeTrackingScopeKind,
) -> DomainRuntimeTrackingScopeKind {
match value {
crate::module_bindings::RuntimeTrackingScopeKind::Site => {
DomainRuntimeTrackingScopeKind::Site
}
crate::module_bindings::RuntimeTrackingScopeKind::Work => {
DomainRuntimeTrackingScopeKind::Work
}
crate::module_bindings::RuntimeTrackingScopeKind::Module => {
DomainRuntimeTrackingScopeKind::Module
}
crate::module_bindings::RuntimeTrackingScopeKind::User => {
DomainRuntimeTrackingScopeKind::User
}
}
}
pub(crate) fn map_runtime_profile_task_cycle(
value: DomainRuntimeProfileTaskCycle,
) -> crate::module_bindings::RuntimeProfileTaskCycle {
match value {
DomainRuntimeProfileTaskCycle::Daily => {
crate::module_bindings::RuntimeProfileTaskCycle::Daily
}
}
}
pub(crate) fn map_runtime_profile_task_cycle_back(
value: crate::module_bindings::RuntimeProfileTaskCycle,
) -> DomainRuntimeProfileTaskCycle {
match value {
crate::module_bindings::RuntimeProfileTaskCycle::Daily => {
DomainRuntimeProfileTaskCycle::Daily
}
}
}
pub(crate) fn map_runtime_profile_task_status_back(
value: crate::module_bindings::RuntimeProfileTaskStatus,
) -> DomainRuntimeProfileTaskStatus {
match value {
crate::module_bindings::RuntimeProfileTaskStatus::Incomplete => {
DomainRuntimeProfileTaskStatus::Incomplete
}
crate::module_bindings::RuntimeProfileTaskStatus::Claimable => {
DomainRuntimeProfileTaskStatus::Claimable
}
crate::module_bindings::RuntimeProfileTaskStatus::Claimed => {
DomainRuntimeProfileTaskStatus::Claimed
}
crate::module_bindings::RuntimeProfileTaskStatus::Disabled => {
DomainRuntimeProfileTaskStatus::Disabled
}
} }
} }

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_config_admin_disable_input_type::RuntimeProfileTaskConfigAdminDisableInput;
use super::runtime_profile_task_config_admin_procedure_result_type::RuntimeProfileTaskConfigAdminProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminDisableProfileTaskConfigArgs {
pub input: RuntimeProfileTaskConfigAdminDisableInput,
}
impl __sdk::InModule for AdminDisableProfileTaskConfigArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_disable_profile_task_config`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_disable_profile_task_config {
fn admin_disable_profile_task_config(&self, input: RuntimeProfileTaskConfigAdminDisableInput) {
self.admin_disable_profile_task_config_then(input, |_, _| {});
}
fn admin_disable_profile_task_config_then(
&self,
input: RuntimeProfileTaskConfigAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_disable_profile_task_config for super::RemoteProcedures {
fn admin_disable_profile_task_config_then(
&self,
input: RuntimeProfileTaskConfigAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>(
"admin_disable_profile_task_config",
AdminDisableProfileTaskConfigArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_invite_code_admin_list_input_type::RuntimeProfileInviteCodeAdminListInput;
use super::runtime_profile_invite_code_admin_list_procedure_result_type::RuntimeProfileInviteCodeAdminListProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminListProfileInviteCodesArgs {
pub input: RuntimeProfileInviteCodeAdminListInput,
}
impl __sdk::InModule for AdminListProfileInviteCodesArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_list_profile_invite_codes`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_list_profile_invite_codes {
fn admin_list_profile_invite_codes(&self, input: RuntimeProfileInviteCodeAdminListInput) {
self.admin_list_profile_invite_codes_then(input, |_, _| {});
}
fn admin_list_profile_invite_codes_then(
&self,
input: RuntimeProfileInviteCodeAdminListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileInviteCodeAdminListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_list_profile_invite_codes for super::RemoteProcedures {
fn admin_list_profile_invite_codes_then(
&self,
input: RuntimeProfileInviteCodeAdminListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileInviteCodeAdminListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminListProcedureResult>(
"admin_list_profile_invite_codes",
AdminListProfileInviteCodesArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_redeem_code_admin_list_input_type::RuntimeProfileRedeemCodeAdminListInput;
use super::runtime_profile_redeem_code_admin_list_procedure_result_type::RuntimeProfileRedeemCodeAdminListProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminListProfileRedeemCodesArgs {
pub input: RuntimeProfileRedeemCodeAdminListInput,
}
impl __sdk::InModule for AdminListProfileRedeemCodesArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_list_profile_redeem_codes`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_list_profile_redeem_codes {
fn admin_list_profile_redeem_codes(&self, input: RuntimeProfileRedeemCodeAdminListInput) {
self.admin_list_profile_redeem_codes_then(input, |_, _| {});
}
fn admin_list_profile_redeem_codes_then(
&self,
input: RuntimeProfileRedeemCodeAdminListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_list_profile_redeem_codes for super::RemoteProcedures {
fn admin_list_profile_redeem_codes_then(
&self,
input: RuntimeProfileRedeemCodeAdminListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminListProcedureResult>(
"admin_list_profile_redeem_codes",
AdminListProfileRedeemCodesArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_config_admin_list_input_type::RuntimeProfileTaskConfigAdminListInput;
use super::runtime_profile_task_config_admin_list_procedure_result_type::RuntimeProfileTaskConfigAdminListProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminListProfileTaskConfigsArgs {
pub input: RuntimeProfileTaskConfigAdminListInput,
}
impl __sdk::InModule for AdminListProfileTaskConfigsArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_list_profile_task_configs`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_list_profile_task_configs {
fn admin_list_profile_task_configs(&self, input: RuntimeProfileTaskConfigAdminListInput) {
self.admin_list_profile_task_configs_then(input, |_, _| {});
}
fn admin_list_profile_task_configs_then(
&self,
input: RuntimeProfileTaskConfigAdminListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_list_profile_task_configs for super::RemoteProcedures {
fn admin_list_profile_task_configs_then(
&self,
input: RuntimeProfileTaskConfigAdminListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminListProcedureResult>(
"admin_list_profile_task_configs",
AdminListProfileTaskConfigsArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_config_admin_procedure_result_type::RuntimeProfileTaskConfigAdminProcedureResult;
use super::runtime_profile_task_config_admin_upsert_input_type::RuntimeProfileTaskConfigAdminUpsertInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminUpsertProfileTaskConfigArgs {
pub input: RuntimeProfileTaskConfigAdminUpsertInput,
}
impl __sdk::InModule for AdminUpsertProfileTaskConfigArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_upsert_profile_task_config`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_upsert_profile_task_config {
fn admin_upsert_profile_task_config(&self, input: RuntimeProfileTaskConfigAdminUpsertInput) {
self.admin_upsert_profile_task_config_then(input, |_, _| {});
}
fn admin_upsert_profile_task_config_then(
&self,
input: RuntimeProfileTaskConfigAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_upsert_profile_task_config for super::RemoteProcedures {
fn admin_upsert_profile_task_config_then(
&self,
input: RuntimeProfileTaskConfigAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>(
"admin_upsert_profile_task_config",
AdminUpsertProfileTaskConfigArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_claim_input_type::RuntimeProfileTaskClaimInput;
use super::runtime_profile_task_claim_procedure_result_type::RuntimeProfileTaskClaimProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ClaimProfileTaskRewardAndReturnArgs {
pub input: RuntimeProfileTaskClaimInput,
}
impl __sdk::InModule for ClaimProfileTaskRewardAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `claim_profile_task_reward_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait claim_profile_task_reward_and_return {
fn claim_profile_task_reward_and_return(&self, input: RuntimeProfileTaskClaimInput) {
self.claim_profile_task_reward_and_return_then(input, |_, _| {});
}
fn claim_profile_task_reward_and_return_then(
&self,
input: RuntimeProfileTaskClaimInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskClaimProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl claim_profile_task_reward_and_return for super::RemoteProcedures {
fn claim_profile_task_reward_and_return_then(
&self,
input: RuntimeProfileTaskClaimInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskClaimProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskClaimProcedureResult>(
"claim_profile_task_reward_and_return",
ClaimProfileTaskRewardAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_center_get_input_type::RuntimeProfileTaskCenterGetInput;
use super::runtime_profile_task_center_procedure_result_type::RuntimeProfileTaskCenterProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GetProfileTaskCenterArgs {
pub input: RuntimeProfileTaskCenterGetInput,
}
impl __sdk::InModule for GetProfileTaskCenterArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_profile_task_center`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_profile_task_center {
fn get_profile_task_center(&self, input: RuntimeProfileTaskCenterGetInput) {
self.get_profile_task_center_then(input, |_, _| {});
}
fn get_profile_task_center_then(
&self,
input: RuntimeProfileTaskCenterGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskCenterProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl get_profile_task_center for super::RemoteProcedures {
fn get_profile_task_center_then(
&self,
input: RuntimeProfileTaskCenterGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskCenterProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskCenterProcedureResult>(
"get_profile_task_center",
GetProfileTaskCenterArgs { input },
__callback,
);
}
}

View File

@@ -9,8 +9,13 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
pub mod accept_quest_reducer; pub mod accept_quest_reducer;
pub mod acknowledge_quest_completion_reducer; pub mod acknowledge_quest_completion_reducer;
pub mod admin_disable_profile_redeem_code_procedure; pub mod admin_disable_profile_redeem_code_procedure;
pub mod admin_disable_profile_task_config_procedure;
pub mod admin_list_profile_invite_codes_procedure;
pub mod admin_list_profile_redeem_codes_procedure;
pub mod admin_list_profile_task_configs_procedure;
pub mod admin_upsert_profile_invite_code_procedure; pub mod admin_upsert_profile_invite_code_procedure;
pub mod admin_upsert_profile_redeem_code_procedure; pub mod admin_upsert_profile_redeem_code_procedure;
pub mod admin_upsert_profile_task_config_procedure;
pub mod advance_puzzle_next_level_procedure; pub mod advance_puzzle_next_level_procedure;
pub mod ai_result_reference_input_type; pub mod ai_result_reference_input_type;
pub mod ai_result_reference_kind_type; pub mod ai_result_reference_kind_type;
@@ -129,6 +134,7 @@ pub mod chapter_progression_ledger_input_type;
pub mod chapter_progression_procedure_result_type; pub mod chapter_progression_procedure_result_type;
pub mod chapter_progression_snapshot_type; pub mod chapter_progression_snapshot_type;
pub mod chapter_progression_type; pub mod chapter_progression_type;
pub mod claim_profile_task_reward_and_return_procedure;
pub mod claim_puzzle_work_point_incentive_procedure; pub mod claim_puzzle_work_point_incentive_procedure;
pub mod clear_database_migration_import_chunks_procedure; pub mod clear_database_migration_import_chunks_procedure;
pub mod clear_platform_browse_history_and_return_procedure; pub mod clear_platform_browse_history_and_return_procedure;
@@ -260,6 +266,7 @@ pub mod get_profile_dashboard_procedure;
pub mod get_profile_play_stats_procedure; pub mod get_profile_play_stats_procedure;
pub mod get_profile_recharge_center_procedure; pub mod get_profile_recharge_center_procedure;
pub mod get_profile_referral_invite_center_procedure; pub mod get_profile_referral_invite_center_procedure;
pub mod get_profile_task_center_procedure;
pub mod get_puzzle_agent_session_procedure; pub mod get_puzzle_agent_session_procedure;
pub mod get_puzzle_gallery_detail_procedure; pub mod get_puzzle_gallery_detail_procedure;
pub mod get_puzzle_run_procedure; pub mod get_puzzle_run_procedure;
@@ -351,6 +358,9 @@ pub mod profile_redeem_code_type;
pub mod profile_redeem_code_usage_type; pub mod profile_redeem_code_usage_type;
pub mod profile_referral_relation_type; pub mod profile_referral_relation_type;
pub mod profile_save_archive_type; pub mod profile_save_archive_type;
pub mod profile_task_config_type;
pub mod profile_task_progress_type;
pub mod profile_task_reward_claim_type;
pub mod profile_wallet_ledger_type; pub mod profile_wallet_ledger_type;
pub mod public_work_like_type; pub mod public_work_like_type;
pub mod public_work_play_daily_stat_type; pub mod public_work_play_daily_stat_type;
@@ -482,6 +492,8 @@ pub mod runtime_platform_theme_type;
pub mod runtime_profile_dashboard_get_input_type; pub mod runtime_profile_dashboard_get_input_type;
pub mod runtime_profile_dashboard_procedure_result_type; pub mod runtime_profile_dashboard_procedure_result_type;
pub mod runtime_profile_dashboard_snapshot_type; pub mod runtime_profile_dashboard_snapshot_type;
pub mod runtime_profile_invite_code_admin_list_input_type;
pub mod runtime_profile_invite_code_admin_list_procedure_result_type;
pub mod runtime_profile_invite_code_admin_procedure_result_type; pub mod runtime_profile_invite_code_admin_procedure_result_type;
pub mod runtime_profile_invite_code_admin_upsert_input_type; pub mod runtime_profile_invite_code_admin_upsert_input_type;
pub mod runtime_profile_invite_code_snapshot_type; pub mod runtime_profile_invite_code_snapshot_type;
@@ -502,6 +514,8 @@ pub mod runtime_profile_recharge_order_status_type;
pub mod runtime_profile_recharge_product_kind_type; pub mod runtime_profile_recharge_product_kind_type;
pub mod runtime_profile_recharge_product_snapshot_type; pub mod runtime_profile_recharge_product_snapshot_type;
pub mod runtime_profile_redeem_code_admin_disable_input_type; pub mod runtime_profile_redeem_code_admin_disable_input_type;
pub mod runtime_profile_redeem_code_admin_list_input_type;
pub mod runtime_profile_redeem_code_admin_list_procedure_result_type;
pub mod runtime_profile_redeem_code_admin_procedure_result_type; pub mod runtime_profile_redeem_code_admin_procedure_result_type;
pub mod runtime_profile_redeem_code_admin_upsert_input_type; pub mod runtime_profile_redeem_code_admin_upsert_input_type;
pub mod runtime_profile_redeem_code_mode_type; pub mod runtime_profile_redeem_code_mode_type;
@@ -513,6 +527,21 @@ pub mod runtime_profile_save_archive_list_input_type;
pub mod runtime_profile_save_archive_procedure_result_type; pub mod runtime_profile_save_archive_procedure_result_type;
pub mod runtime_profile_save_archive_resume_input_type; pub mod runtime_profile_save_archive_resume_input_type;
pub mod runtime_profile_save_archive_snapshot_type; pub mod runtime_profile_save_archive_snapshot_type;
pub mod runtime_profile_task_center_get_input_type;
pub mod runtime_profile_task_center_procedure_result_type;
pub mod runtime_profile_task_center_snapshot_type;
pub mod runtime_profile_task_claim_input_type;
pub mod runtime_profile_task_claim_procedure_result_type;
pub mod runtime_profile_task_claim_snapshot_type;
pub mod runtime_profile_task_config_admin_disable_input_type;
pub mod runtime_profile_task_config_admin_list_input_type;
pub mod runtime_profile_task_config_admin_list_procedure_result_type;
pub mod runtime_profile_task_config_admin_procedure_result_type;
pub mod runtime_profile_task_config_admin_upsert_input_type;
pub mod runtime_profile_task_config_snapshot_type;
pub mod runtime_profile_task_cycle_type;
pub mod runtime_profile_task_item_snapshot_type;
pub mod runtime_profile_task_status_type;
pub mod runtime_profile_wallet_adjustment_input_type; pub mod runtime_profile_wallet_adjustment_input_type;
pub mod runtime_profile_wallet_adjustment_procedure_result_type; pub mod runtime_profile_wallet_adjustment_procedure_result_type;
pub mod runtime_profile_wallet_ledger_entry_snapshot_type; pub mod runtime_profile_wallet_ledger_entry_snapshot_type;
@@ -537,6 +566,7 @@ pub mod runtime_snapshot_procedure_result_type;
pub mod runtime_snapshot_row_type; pub mod runtime_snapshot_row_type;
pub mod runtime_snapshot_type; pub mod runtime_snapshot_type;
pub mod runtime_snapshot_upsert_input_type; pub mod runtime_snapshot_upsert_input_type;
pub mod runtime_tracking_scope_kind_type;
pub mod save_puzzle_form_draft_procedure; pub mod save_puzzle_form_draft_procedure;
pub mod save_puzzle_generated_images_procedure; pub mod save_puzzle_generated_images_procedure;
pub mod select_puzzle_cover_image_procedure; pub mod select_puzzle_cover_image_procedure;
@@ -564,6 +594,8 @@ pub mod submit_match_3_d_agent_message_procedure;
pub mod submit_puzzle_agent_message_procedure; pub mod submit_puzzle_agent_message_procedure;
pub mod submit_puzzle_leaderboard_entry_procedure; pub mod submit_puzzle_leaderboard_entry_procedure;
pub mod swap_puzzle_pieces_procedure; pub mod swap_puzzle_pieces_procedure;
pub mod tracking_daily_stat_type;
pub mod tracking_event_type;
pub mod treasure_interaction_action_type; pub mod treasure_interaction_action_type;
pub mod treasure_record_procedure_result_type; pub mod treasure_record_procedure_result_type;
pub mod treasure_record_snapshot_type; pub mod treasure_record_snapshot_type;
@@ -594,8 +626,13 @@ pub mod user_browse_history_type;
pub use accept_quest_reducer::accept_quest; pub use accept_quest_reducer::accept_quest;
pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion;
pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code; pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code;
pub use admin_disable_profile_task_config_procedure::admin_disable_profile_task_config;
pub use admin_list_profile_invite_codes_procedure::admin_list_profile_invite_codes;
pub use admin_list_profile_redeem_codes_procedure::admin_list_profile_redeem_codes;
pub use admin_list_profile_task_configs_procedure::admin_list_profile_task_configs;
pub use admin_upsert_profile_invite_code_procedure::admin_upsert_profile_invite_code; pub use admin_upsert_profile_invite_code_procedure::admin_upsert_profile_invite_code;
pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code; pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code;
pub use admin_upsert_profile_task_config_procedure::admin_upsert_profile_task_config;
pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level; pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level;
pub use ai_result_reference_input_type::AiResultReferenceInput; pub use ai_result_reference_input_type::AiResultReferenceInput;
pub use ai_result_reference_kind_type::AiResultReferenceKind; pub use ai_result_reference_kind_type::AiResultReferenceKind;
@@ -714,6 +751,7 @@ pub use chapter_progression_ledger_input_type::ChapterProgressionLedgerInput;
pub use chapter_progression_procedure_result_type::ChapterProgressionProcedureResult; pub use chapter_progression_procedure_result_type::ChapterProgressionProcedureResult;
pub use chapter_progression_snapshot_type::ChapterProgressionSnapshot; pub use chapter_progression_snapshot_type::ChapterProgressionSnapshot;
pub use chapter_progression_type::ChapterProgression; pub use chapter_progression_type::ChapterProgression;
pub use claim_profile_task_reward_and_return_procedure::claim_profile_task_reward_and_return;
pub use claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive; pub use claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive;
pub use clear_database_migration_import_chunks_procedure::clear_database_migration_import_chunks; pub use clear_database_migration_import_chunks_procedure::clear_database_migration_import_chunks;
pub use clear_platform_browse_history_and_return_procedure::clear_platform_browse_history_and_return; pub use clear_platform_browse_history_and_return_procedure::clear_platform_browse_history_and_return;
@@ -845,6 +883,7 @@ pub use get_profile_dashboard_procedure::get_profile_dashboard;
pub use get_profile_play_stats_procedure::get_profile_play_stats; pub use get_profile_play_stats_procedure::get_profile_play_stats;
pub use get_profile_recharge_center_procedure::get_profile_recharge_center; pub use get_profile_recharge_center_procedure::get_profile_recharge_center;
pub use get_profile_referral_invite_center_procedure::get_profile_referral_invite_center; pub use get_profile_referral_invite_center_procedure::get_profile_referral_invite_center;
pub use get_profile_task_center_procedure::get_profile_task_center;
pub use get_puzzle_agent_session_procedure::get_puzzle_agent_session; pub use get_puzzle_agent_session_procedure::get_puzzle_agent_session;
pub use get_puzzle_gallery_detail_procedure::get_puzzle_gallery_detail; pub use get_puzzle_gallery_detail_procedure::get_puzzle_gallery_detail;
pub use get_puzzle_run_procedure::get_puzzle_run; pub use get_puzzle_run_procedure::get_puzzle_run;
@@ -936,6 +975,9 @@ pub use profile_redeem_code_type::ProfileRedeemCode;
pub use profile_redeem_code_usage_type::ProfileRedeemCodeUsage; pub use profile_redeem_code_usage_type::ProfileRedeemCodeUsage;
pub use profile_referral_relation_type::ProfileReferralRelation; pub use profile_referral_relation_type::ProfileReferralRelation;
pub use profile_save_archive_type::ProfileSaveArchive; pub use profile_save_archive_type::ProfileSaveArchive;
pub use profile_task_config_type::ProfileTaskConfig;
pub use profile_task_progress_type::ProfileTaskProgress;
pub use profile_task_reward_claim_type::ProfileTaskRewardClaim;
pub use profile_wallet_ledger_type::ProfileWalletLedger; pub use profile_wallet_ledger_type::ProfileWalletLedger;
pub use public_work_like_type::PublicWorkLike; pub use public_work_like_type::PublicWorkLike;
pub use public_work_play_daily_stat_type::PublicWorkPlayDailyStat; pub use public_work_play_daily_stat_type::PublicWorkPlayDailyStat;
@@ -1067,6 +1109,8 @@ pub use runtime_platform_theme_type::RuntimePlatformTheme;
pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput; pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput;
pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult; pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult;
pub use runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot; pub use runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot;
pub use runtime_profile_invite_code_admin_list_input_type::RuntimeProfileInviteCodeAdminListInput;
pub use runtime_profile_invite_code_admin_list_procedure_result_type::RuntimeProfileInviteCodeAdminListProcedureResult;
pub use runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult; pub use runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult;
pub use runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInviteCodeAdminUpsertInput; pub use runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInviteCodeAdminUpsertInput;
pub use runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; pub use runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot;
@@ -1087,6 +1131,8 @@ pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderS
pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
pub use runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot; pub use runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot;
pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput;
pub use runtime_profile_redeem_code_admin_list_input_type::RuntimeProfileRedeemCodeAdminListInput;
pub use runtime_profile_redeem_code_admin_list_procedure_result_type::RuntimeProfileRedeemCodeAdminListProcedureResult;
pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult;
pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput; pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput;
pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
@@ -1098,6 +1144,21 @@ pub use runtime_profile_save_archive_list_input_type::RuntimeProfileSaveArchiveL
pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult; pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult;
pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput; pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput;
pub use runtime_profile_save_archive_snapshot_type::RuntimeProfileSaveArchiveSnapshot; pub use runtime_profile_save_archive_snapshot_type::RuntimeProfileSaveArchiveSnapshot;
pub use runtime_profile_task_center_get_input_type::RuntimeProfileTaskCenterGetInput;
pub use runtime_profile_task_center_procedure_result_type::RuntimeProfileTaskCenterProcedureResult;
pub use runtime_profile_task_center_snapshot_type::RuntimeProfileTaskCenterSnapshot;
pub use runtime_profile_task_claim_input_type::RuntimeProfileTaskClaimInput;
pub use runtime_profile_task_claim_procedure_result_type::RuntimeProfileTaskClaimProcedureResult;
pub use runtime_profile_task_claim_snapshot_type::RuntimeProfileTaskClaimSnapshot;
pub use runtime_profile_task_config_admin_disable_input_type::RuntimeProfileTaskConfigAdminDisableInput;
pub use runtime_profile_task_config_admin_list_input_type::RuntimeProfileTaskConfigAdminListInput;
pub use runtime_profile_task_config_admin_list_procedure_result_type::RuntimeProfileTaskConfigAdminListProcedureResult;
pub use runtime_profile_task_config_admin_procedure_result_type::RuntimeProfileTaskConfigAdminProcedureResult;
pub use runtime_profile_task_config_admin_upsert_input_type::RuntimeProfileTaskConfigAdminUpsertInput;
pub use runtime_profile_task_config_snapshot_type::RuntimeProfileTaskConfigSnapshot;
pub use runtime_profile_task_cycle_type::RuntimeProfileTaskCycle;
pub use runtime_profile_task_item_snapshot_type::RuntimeProfileTaskItemSnapshot;
pub use runtime_profile_task_status_type::RuntimeProfileTaskStatus;
pub use runtime_profile_wallet_adjustment_input_type::RuntimeProfileWalletAdjustmentInput; pub use runtime_profile_wallet_adjustment_input_type::RuntimeProfileWalletAdjustmentInput;
pub use runtime_profile_wallet_adjustment_procedure_result_type::RuntimeProfileWalletAdjustmentProcedureResult; pub use runtime_profile_wallet_adjustment_procedure_result_type::RuntimeProfileWalletAdjustmentProcedureResult;
pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot; pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot;
@@ -1122,6 +1183,7 @@ pub use runtime_snapshot_procedure_result_type::RuntimeSnapshotProcedureResult;
pub use runtime_snapshot_row_type::RuntimeSnapshotRow; pub use runtime_snapshot_row_type::RuntimeSnapshotRow;
pub use runtime_snapshot_type::RuntimeSnapshot; pub use runtime_snapshot_type::RuntimeSnapshot;
pub use runtime_snapshot_upsert_input_type::RuntimeSnapshotUpsertInput; pub use runtime_snapshot_upsert_input_type::RuntimeSnapshotUpsertInput;
pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft; pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft;
pub use save_puzzle_generated_images_procedure::save_puzzle_generated_images; pub use save_puzzle_generated_images_procedure::save_puzzle_generated_images;
pub use select_puzzle_cover_image_procedure::select_puzzle_cover_image; pub use select_puzzle_cover_image_procedure::select_puzzle_cover_image;
@@ -1149,6 +1211,8 @@ pub use submit_match_3_d_agent_message_procedure::submit_match_3_d_agent_message
pub use submit_puzzle_agent_message_procedure::submit_puzzle_agent_message; pub use submit_puzzle_agent_message_procedure::submit_puzzle_agent_message;
pub use submit_puzzle_leaderboard_entry_procedure::submit_puzzle_leaderboard_entry; pub use submit_puzzle_leaderboard_entry_procedure::submit_puzzle_leaderboard_entry;
pub use swap_puzzle_pieces_procedure::swap_puzzle_pieces; pub use swap_puzzle_pieces_procedure::swap_puzzle_pieces;
pub use tracking_daily_stat_type::TrackingDailyStat;
pub use tracking_event_type::TrackingEvent;
pub use treasure_interaction_action_type::TreasureInteractionAction; pub use treasure_interaction_action_type::TreasureInteractionAction;
pub use treasure_record_procedure_result_type::TreasureRecordProcedureResult; pub use treasure_record_procedure_result_type::TreasureRecordProcedureResult;
pub use treasure_record_snapshot_type::TreasureRecordSnapshot; pub use treasure_record_snapshot_type::TreasureRecordSnapshot;

View File

@@ -0,0 +1,91 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_cycle_type::RuntimeProfileTaskCycle;
use super::runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ProfileTaskConfig {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub scope_kind: RuntimeTrackingScopeKind,
pub threshold: u32,
pub reward_points: u64,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at: __sdk::Timestamp,
pub updated_by: String,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for ProfileTaskConfig {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `ProfileTaskConfig`.
///
/// Provides typed access to columns for query building.
pub struct ProfileTaskConfigCols {
pub task_id: __sdk::__query_builder::Col<ProfileTaskConfig, String>,
pub title: __sdk::__query_builder::Col<ProfileTaskConfig, String>,
pub description: __sdk::__query_builder::Col<ProfileTaskConfig, String>,
pub event_key: __sdk::__query_builder::Col<ProfileTaskConfig, String>,
pub cycle: __sdk::__query_builder::Col<ProfileTaskConfig, RuntimeProfileTaskCycle>,
pub scope_kind: __sdk::__query_builder::Col<ProfileTaskConfig, RuntimeTrackingScopeKind>,
pub threshold: __sdk::__query_builder::Col<ProfileTaskConfig, u32>,
pub reward_points: __sdk::__query_builder::Col<ProfileTaskConfig, u64>,
pub enabled: __sdk::__query_builder::Col<ProfileTaskConfig, bool>,
pub sort_order: __sdk::__query_builder::Col<ProfileTaskConfig, i32>,
pub created_by: __sdk::__query_builder::Col<ProfileTaskConfig, String>,
pub created_at: __sdk::__query_builder::Col<ProfileTaskConfig, __sdk::Timestamp>,
pub updated_by: __sdk::__query_builder::Col<ProfileTaskConfig, String>,
pub updated_at: __sdk::__query_builder::Col<ProfileTaskConfig, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for ProfileTaskConfig {
type Cols = ProfileTaskConfigCols;
fn cols(table_name: &'static str) -> Self::Cols {
ProfileTaskConfigCols {
task_id: __sdk::__query_builder::Col::new(table_name, "task_id"),
title: __sdk::__query_builder::Col::new(table_name, "title"),
description: __sdk::__query_builder::Col::new(table_name, "description"),
event_key: __sdk::__query_builder::Col::new(table_name, "event_key"),
cycle: __sdk::__query_builder::Col::new(table_name, "cycle"),
scope_kind: __sdk::__query_builder::Col::new(table_name, "scope_kind"),
threshold: __sdk::__query_builder::Col::new(table_name, "threshold"),
reward_points: __sdk::__query_builder::Col::new(table_name, "reward_points"),
enabled: __sdk::__query_builder::Col::new(table_name, "enabled"),
sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"),
created_by: __sdk::__query_builder::Col::new(table_name, "created_by"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_by: __sdk::__query_builder::Col::new(table_name, "updated_by"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `ProfileTaskConfig`.
///
/// Provides typed access to indexed columns for query building.
pub struct ProfileTaskConfigIxCols {
pub task_id: __sdk::__query_builder::IxCol<ProfileTaskConfig, String>,
}
impl __sdk::__query_builder::HasIxCols for ProfileTaskConfig {
type IxCols = ProfileTaskConfigIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
ProfileTaskConfigIxCols {
task_id: __sdk::__query_builder::IxCol::new(table_name, "task_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for ProfileTaskConfig {}

View File

@@ -0,0 +1,74 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_status_type::RuntimeProfileTaskStatus;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ProfileTaskProgress {
pub progress_id: String,
pub user_id: String,
pub task_id: String,
pub day_key: i64,
pub progress_count: u32,
pub threshold: u32,
pub status: RuntimeProfileTaskStatus,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for ProfileTaskProgress {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `ProfileTaskProgress`.
///
/// Provides typed access to columns for query building.
pub struct ProfileTaskProgressCols {
pub progress_id: __sdk::__query_builder::Col<ProfileTaskProgress, String>,
pub user_id: __sdk::__query_builder::Col<ProfileTaskProgress, String>,
pub task_id: __sdk::__query_builder::Col<ProfileTaskProgress, String>,
pub day_key: __sdk::__query_builder::Col<ProfileTaskProgress, i64>,
pub progress_count: __sdk::__query_builder::Col<ProfileTaskProgress, u32>,
pub threshold: __sdk::__query_builder::Col<ProfileTaskProgress, u32>,
pub status: __sdk::__query_builder::Col<ProfileTaskProgress, RuntimeProfileTaskStatus>,
pub updated_at: __sdk::__query_builder::Col<ProfileTaskProgress, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for ProfileTaskProgress {
type Cols = ProfileTaskProgressCols;
fn cols(table_name: &'static str) -> Self::Cols {
ProfileTaskProgressCols {
progress_id: __sdk::__query_builder::Col::new(table_name, "progress_id"),
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
task_id: __sdk::__query_builder::Col::new(table_name, "task_id"),
day_key: __sdk::__query_builder::Col::new(table_name, "day_key"),
progress_count: __sdk::__query_builder::Col::new(table_name, "progress_count"),
threshold: __sdk::__query_builder::Col::new(table_name, "threshold"),
status: __sdk::__query_builder::Col::new(table_name, "status"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `ProfileTaskProgress`.
///
/// Provides typed access to indexed columns for query building.
pub struct ProfileTaskProgressIxCols {
pub progress_id: __sdk::__query_builder::IxCol<ProfileTaskProgress, String>,
pub user_id: __sdk::__query_builder::IxCol<ProfileTaskProgress, String>,
}
impl __sdk::__query_builder::HasIxCols for ProfileTaskProgress {
type IxCols = ProfileTaskProgressIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
ProfileTaskProgressIxCols {
progress_id: __sdk::__query_builder::IxCol::new(table_name, "progress_id"),
user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for ProfileTaskProgress {}

View File

@@ -0,0 +1,69 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ProfileTaskRewardClaim {
pub claim_id: String,
pub user_id: String,
pub task_id: String,
pub day_key: i64,
pub reward_points: u64,
pub wallet_ledger_id: String,
pub claimed_at: __sdk::Timestamp,
}
impl __sdk::InModule for ProfileTaskRewardClaim {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `ProfileTaskRewardClaim`.
///
/// Provides typed access to columns for query building.
pub struct ProfileTaskRewardClaimCols {
pub claim_id: __sdk::__query_builder::Col<ProfileTaskRewardClaim, String>,
pub user_id: __sdk::__query_builder::Col<ProfileTaskRewardClaim, String>,
pub task_id: __sdk::__query_builder::Col<ProfileTaskRewardClaim, String>,
pub day_key: __sdk::__query_builder::Col<ProfileTaskRewardClaim, i64>,
pub reward_points: __sdk::__query_builder::Col<ProfileTaskRewardClaim, u64>,
pub wallet_ledger_id: __sdk::__query_builder::Col<ProfileTaskRewardClaim, String>,
pub claimed_at: __sdk::__query_builder::Col<ProfileTaskRewardClaim, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for ProfileTaskRewardClaim {
type Cols = ProfileTaskRewardClaimCols;
fn cols(table_name: &'static str) -> Self::Cols {
ProfileTaskRewardClaimCols {
claim_id: __sdk::__query_builder::Col::new(table_name, "claim_id"),
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
task_id: __sdk::__query_builder::Col::new(table_name, "task_id"),
day_key: __sdk::__query_builder::Col::new(table_name, "day_key"),
reward_points: __sdk::__query_builder::Col::new(table_name, "reward_points"),
wallet_ledger_id: __sdk::__query_builder::Col::new(table_name, "wallet_ledger_id"),
claimed_at: __sdk::__query_builder::Col::new(table_name, "claimed_at"),
}
}
}
/// Indexed column accessor struct for the table `ProfileTaskRewardClaim`.
///
/// Provides typed access to indexed columns for query building.
pub struct ProfileTaskRewardClaimIxCols {
pub claim_id: __sdk::__query_builder::IxCol<ProfileTaskRewardClaim, String>,
pub user_id: __sdk::__query_builder::IxCol<ProfileTaskRewardClaim, String>,
}
impl __sdk::__query_builder::HasIxCols for ProfileTaskRewardClaim {
type IxCols = ProfileTaskRewardClaimIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
ProfileTaskRewardClaimIxCols {
claim_id: __sdk::__query_builder::IxCol::new(table_name, "claim_id"),
user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for ProfileTaskRewardClaim {}

View File

@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileInviteCodeAdminListInput {
pub admin_user_id: String,
}
impl __sdk::InModule for RuntimeProfileInviteCodeAdminListInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileInviteCodeAdminListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileInviteCodeSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileInviteCodeAdminListProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRedeemCodeAdminListInput {
pub admin_user_id: String,
}
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminListInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRedeemCodeAdminListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileRedeemCodeSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminListProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskCenterGetInput {
pub user_id: String,
}
impl __sdk::InModule for RuntimeProfileTaskCenterGetInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_center_snapshot_type::RuntimeProfileTaskCenterSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskCenterProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileTaskCenterSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileTaskCenterProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,21 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_item_snapshot_type::RuntimeProfileTaskItemSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskCenterSnapshot {
pub user_id: String,
pub day_key: i64,
pub wallet_balance: u64,
pub tasks: Vec<RuntimeProfileTaskItemSnapshot>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileTaskCenterSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskClaimInput {
pub user_id: String,
pub task_id: String,
}
impl __sdk::InModule for RuntimeProfileTaskClaimInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_claim_snapshot_type::RuntimeProfileTaskClaimSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskClaimProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileTaskClaimSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileTaskClaimProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,24 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_center_snapshot_type::RuntimeProfileTaskCenterSnapshot;
use super::runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskClaimSnapshot {
pub user_id: String,
pub task_id: String,
pub day_key: i64,
pub reward_points: u64,
pub wallet_balance: u64,
pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot,
pub center: RuntimeProfileTaskCenterSnapshot,
}
impl __sdk::InModule for RuntimeProfileTaskClaimSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskConfigAdminDisableInput {
pub admin_user_id: String,
pub task_id: String,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileTaskConfigAdminDisableInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskConfigAdminListInput {
pub admin_user_id: String,
}
impl __sdk::InModule for RuntimeProfileTaskConfigAdminListInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_config_snapshot_type::RuntimeProfileTaskConfigSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskConfigAdminListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileTaskConfigSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileTaskConfigAdminListProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_config_snapshot_type::RuntimeProfileTaskConfigSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskConfigAdminProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileTaskConfigSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileTaskConfigAdminProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,29 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_cycle_type::RuntimeProfileTaskCycle;
use super::runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskConfigAdminUpsertInput {
pub admin_user_id: String,
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub scope_kind: RuntimeTrackingScopeKind,
pub threshold: u32,
pub reward_points: u64,
pub enabled: bool,
pub sort_order: i32,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileTaskConfigAdminUpsertInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,31 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_cycle_type::RuntimeProfileTaskCycle;
use super::runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskConfigSnapshot {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub scope_kind: RuntimeTrackingScopeKind,
pub threshold: u32,
pub reward_points: u64,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at_micros: i64,
pub updated_by: String,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileTaskConfigSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
#[derive(Copy, Eq, Hash)]
pub enum RuntimeProfileTaskCycle {
Daily,
}
impl __sdk::InModule for RuntimeProfileTaskCycle {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,29 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_cycle_type::RuntimeProfileTaskCycle;
use super::runtime_profile_task_status_type::RuntimeProfileTaskStatus;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskItemSnapshot {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub threshold: u32,
pub progress_count: u32,
pub reward_points: u64,
pub status: RuntimeProfileTaskStatus,
pub day_key: i64,
pub claimed_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileTaskItemSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,22 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
#[derive(Copy, Eq, Hash)]
pub enum RuntimeProfileTaskStatus {
Incomplete,
Claimable,
Claimed,
Disabled,
}
impl __sdk::InModule for RuntimeProfileTaskStatus {
type Module = super::RemoteModule;
}

View File

@@ -25,6 +25,8 @@ pub enum RuntimeProfileWalletLedgerSourceType {
RedeemCodeReward, RedeemCodeReward,
PuzzleAuthorIncentiveClaim, PuzzleAuthorIncentiveClaim,
DailyTaskReward,
} }
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {

View File

@@ -0,0 +1,22 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
#[derive(Copy, Eq, Hash)]
pub enum RuntimeTrackingScopeKind {
Site,
Work,
Module,
User,
}
impl __sdk::InModule for RuntimeTrackingScopeKind {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,75 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct TrackingDailyStat {
pub stat_id: String,
pub event_key: String,
pub scope_kind: RuntimeTrackingScopeKind,
pub scope_id: String,
pub day_key: i64,
pub count: u32,
pub first_occurred_at: __sdk::Timestamp,
pub last_occurred_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for TrackingDailyStat {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `TrackingDailyStat`.
///
/// Provides typed access to columns for query building.
pub struct TrackingDailyStatCols {
pub stat_id: __sdk::__query_builder::Col<TrackingDailyStat, String>,
pub event_key: __sdk::__query_builder::Col<TrackingDailyStat, String>,
pub scope_kind: __sdk::__query_builder::Col<TrackingDailyStat, RuntimeTrackingScopeKind>,
pub scope_id: __sdk::__query_builder::Col<TrackingDailyStat, String>,
pub day_key: __sdk::__query_builder::Col<TrackingDailyStat, i64>,
pub count: __sdk::__query_builder::Col<TrackingDailyStat, u32>,
pub first_occurred_at: __sdk::__query_builder::Col<TrackingDailyStat, __sdk::Timestamp>,
pub last_occurred_at: __sdk::__query_builder::Col<TrackingDailyStat, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<TrackingDailyStat, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for TrackingDailyStat {
type Cols = TrackingDailyStatCols;
fn cols(table_name: &'static str) -> Self::Cols {
TrackingDailyStatCols {
stat_id: __sdk::__query_builder::Col::new(table_name, "stat_id"),
event_key: __sdk::__query_builder::Col::new(table_name, "event_key"),
scope_kind: __sdk::__query_builder::Col::new(table_name, "scope_kind"),
scope_id: __sdk::__query_builder::Col::new(table_name, "scope_id"),
day_key: __sdk::__query_builder::Col::new(table_name, "day_key"),
count: __sdk::__query_builder::Col::new(table_name, "count"),
first_occurred_at: __sdk::__query_builder::Col::new(table_name, "first_occurred_at"),
last_occurred_at: __sdk::__query_builder::Col::new(table_name, "last_occurred_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `TrackingDailyStat`.
///
/// Provides typed access to indexed columns for query building.
pub struct TrackingDailyStatIxCols {
pub stat_id: __sdk::__query_builder::IxCol<TrackingDailyStat, String>,
}
impl __sdk::__query_builder::HasIxCols for TrackingDailyStat {
type IxCols = TrackingDailyStatIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
TrackingDailyStatIxCols {
stat_id: __sdk::__query_builder::IxCol::new(table_name, "stat_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for TrackingDailyStat {}

View File

@@ -0,0 +1,83 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct TrackingEvent {
pub event_id: String,
pub event_key: String,
pub scope_kind: RuntimeTrackingScopeKind,
pub scope_id: String,
pub day_key: i64,
pub user_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub module_key: Option<String>,
pub metadata_json: String,
pub occurred_at: __sdk::Timestamp,
}
impl __sdk::InModule for TrackingEvent {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `TrackingEvent`.
///
/// Provides typed access to columns for query building.
pub struct TrackingEventCols {
pub event_id: __sdk::__query_builder::Col<TrackingEvent, String>,
pub event_key: __sdk::__query_builder::Col<TrackingEvent, String>,
pub scope_kind: __sdk::__query_builder::Col<TrackingEvent, RuntimeTrackingScopeKind>,
pub scope_id: __sdk::__query_builder::Col<TrackingEvent, String>,
pub day_key: __sdk::__query_builder::Col<TrackingEvent, i64>,
pub user_id: __sdk::__query_builder::Col<TrackingEvent, Option<String>>,
pub owner_user_id: __sdk::__query_builder::Col<TrackingEvent, Option<String>>,
pub profile_id: __sdk::__query_builder::Col<TrackingEvent, Option<String>>,
pub module_key: __sdk::__query_builder::Col<TrackingEvent, Option<String>>,
pub metadata_json: __sdk::__query_builder::Col<TrackingEvent, String>,
pub occurred_at: __sdk::__query_builder::Col<TrackingEvent, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for TrackingEvent {
type Cols = TrackingEventCols;
fn cols(table_name: &'static str) -> Self::Cols {
TrackingEventCols {
event_id: __sdk::__query_builder::Col::new(table_name, "event_id"),
event_key: __sdk::__query_builder::Col::new(table_name, "event_key"),
scope_kind: __sdk::__query_builder::Col::new(table_name, "scope_kind"),
scope_id: __sdk::__query_builder::Col::new(table_name, "scope_id"),
day_key: __sdk::__query_builder::Col::new(table_name, "day_key"),
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"),
module_key: __sdk::__query_builder::Col::new(table_name, "module_key"),
metadata_json: __sdk::__query_builder::Col::new(table_name, "metadata_json"),
occurred_at: __sdk::__query_builder::Col::new(table_name, "occurred_at"),
}
}
}
/// Indexed column accessor struct for the table `TrackingEvent`.
///
/// Provides typed access to indexed columns for query building.
pub struct TrackingEventIxCols {
pub event_id: __sdk::__query_builder::IxCol<TrackingEvent, String>,
pub event_key: __sdk::__query_builder::IxCol<TrackingEvent, String>,
}
impl __sdk::__query_builder::HasIxCols for TrackingEvent {
type IxCols = TrackingEventIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
TrackingEventIxCols {
event_id: __sdk::__query_builder::IxCol::new(table_name, "event_id"),
event_key: __sdk::__query_builder::IxCol::new(table_name, "event_key"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for TrackingEvent {}

View File

@@ -304,6 +304,143 @@ impl SpacetimeClient {
.await .await
} }
pub async fn get_profile_task_center(
&self,
user_id: String,
) -> Result<RuntimeProfileTaskCenterRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_task_center_get_input(user_id)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection.procedures().get_profile_task_center_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_task_center_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn claim_profile_task_reward(
&self,
user_id: String,
task_id: String,
) -> Result<RuntimeProfileTaskClaimRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_task_claim_input(user_id, task_id)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.claim_profile_task_reward_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_task_claim_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn admin_list_profile_task_configs(
&self,
admin_user_id: String,
) -> Result<Vec<RuntimeProfileTaskConfigRecord>, SpacetimeClientError> {
let procedure_input = build_runtime_profile_task_config_admin_list_input(admin_user_id)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_list_profile_task_configs_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_task_config_admin_list_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn admin_upsert_profile_task_config(
&self,
admin_user_id: String,
task_id: String,
title: String,
description: String,
event_key: String,
cycle: DomainRuntimeProfileTaskCycle,
scope_kind: DomainRuntimeTrackingScopeKind,
threshold: u32,
reward_points: u64,
enabled: bool,
sort_order: i32,
updated_at_micros: i64,
) -> Result<RuntimeProfileTaskConfigRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_task_config_admin_upsert_input(
admin_user_id,
task_id,
title,
description,
event_key,
cycle,
scope_kind,
threshold,
reward_points,
enabled,
sort_order,
updated_at_micros,
)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_upsert_profile_task_config_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_task_config_admin_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn admin_disable_profile_task_config(
&self,
admin_user_id: String,
task_id: String,
updated_at_micros: i64,
) -> Result<RuntimeProfileTaskConfigRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_task_config_admin_disable_input(
admin_user_id,
task_id,
updated_at_micros,
)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_disable_profile_task_config_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_task_config_admin_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn admin_upsert_profile_redeem_code( pub async fn admin_upsert_profile_redeem_code(
&self, &self,
admin_user_id: String, admin_user_id: String,
@@ -343,6 +480,27 @@ impl SpacetimeClient {
.await .await
} }
pub async fn admin_list_profile_redeem_codes(
&self,
admin_user_id: String,
) -> Result<Vec<RuntimeProfileRedeemCodeRecord>, SpacetimeClientError> {
let procedure_input = build_runtime_profile_redeem_code_admin_list_input(admin_user_id)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_list_profile_redeem_codes_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_redeem_code_admin_list_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn admin_disable_profile_redeem_code( pub async fn admin_disable_profile_redeem_code(
&self, &self,
admin_user_id: String, admin_user_id: String,
@@ -399,6 +557,27 @@ impl SpacetimeClient {
.await .await
} }
pub async fn admin_list_profile_invite_codes(
&self,
admin_user_id: String,
) -> Result<Vec<RuntimeProfileInviteCodeRecord>, SpacetimeClientError> {
let procedure_input = build_runtime_profile_invite_code_admin_list_input(admin_user_id)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_list_profile_invite_codes_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_invite_code_admin_list_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_profile_play_stats( pub async fn get_profile_play_stats(
&self, &self,
user_id: String, user_id: String,

View File

@@ -161,6 +161,11 @@ macro_rules! migration_tables {
user_browse_history, user_browse_history,
profile_dashboard_state, profile_dashboard_state,
profile_wallet_ledger, profile_wallet_ledger,
tracking_event,
tracking_daily_stat,
profile_task_config,
profile_task_progress,
profile_task_reward_claim,
profile_redeem_code, profile_redeem_code,
profile_redeem_code_usage, profile_redeem_code_usage,
profile_invite_code, profile_invite_code,

View File

@@ -4,6 +4,10 @@ const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000;
const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7; const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7;
const PROFILE_REFERRAL_INVITED_USERS_LIMIT: usize = 20; const PROFILE_REFERRAL_INVITED_USERS_LIMIT: usize = 20;
const PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX: &str = "new-user-registration"; const PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX: &str = "new-user-registration";
const PROFILE_TASK_SYSTEM_USER_ID: &str = "system:profile-task";
const PROFILE_TASK_LOGIN_EVENT_ID_PREFIX: &str = "daily-login";
const PROFILE_TRACKING_SITE_SCOPE_ID: &str = "site";
const PROFILE_TRACKING_PROFILE_MODULE_KEY: &str = "profile";
#[spacetimedb::table(accessor = profile_dashboard_state)] #[spacetimedb::table(accessor = profile_dashboard_state)]
pub struct ProfileDashboardState { pub struct ProfileDashboardState {
@@ -33,6 +37,115 @@ pub struct ProfileWalletLedger {
pub(crate) created_at: Timestamp, pub(crate) created_at: Timestamp,
} }
#[spacetimedb::table(
accessor = tracking_event,
index(accessor = by_tracking_event_event_key, btree(columns = [event_key])),
index(
accessor = by_tracking_event_scope,
btree(columns = [scope_kind, scope_id])
),
index(
accessor = by_tracking_event_user,
btree(columns = [user_id, occurred_at])
)
)]
pub struct TrackingEvent {
#[primary_key]
pub(crate) event_id: String,
pub(crate) event_key: String,
pub(crate) scope_kind: RuntimeTrackingScopeKind,
pub(crate) scope_id: String,
pub(crate) day_key: i64,
pub(crate) user_id: Option<String>,
pub(crate) owner_user_id: Option<String>,
pub(crate) profile_id: Option<String>,
pub(crate) module_key: Option<String>,
pub(crate) metadata_json: String,
pub(crate) occurred_at: Timestamp,
}
#[spacetimedb::table(
accessor = tracking_daily_stat,
index(
accessor = by_tracking_daily_stat_event_day,
btree(columns = [event_key, day_key])
),
index(
accessor = by_tracking_daily_stat_scope_day,
btree(columns = [scope_kind, scope_id, day_key])
)
)]
pub struct TrackingDailyStat {
#[primary_key]
pub(crate) stat_id: String,
pub(crate) event_key: String,
pub(crate) scope_kind: RuntimeTrackingScopeKind,
pub(crate) scope_id: String,
pub(crate) day_key: i64,
pub(crate) count: u32,
pub(crate) first_occurred_at: Timestamp,
pub(crate) last_occurred_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(accessor = profile_task_config)]
pub struct ProfileTaskConfig {
#[primary_key]
pub(crate) task_id: String,
pub(crate) title: String,
pub(crate) description: String,
pub(crate) event_key: String,
pub(crate) cycle: RuntimeProfileTaskCycle,
pub(crate) scope_kind: RuntimeTrackingScopeKind,
pub(crate) threshold: u32,
pub(crate) reward_points: u64,
pub(crate) enabled: bool,
pub(crate) sort_order: i32,
pub(crate) created_by: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_by: String,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_task_progress,
index(accessor = by_profile_task_progress_user, btree(columns = [user_id])),
index(
accessor = by_profile_task_progress_user_task,
btree(columns = [user_id, task_id])
)
)]
pub struct ProfileTaskProgress {
#[primary_key]
pub(crate) progress_id: String,
pub(crate) user_id: String,
pub(crate) task_id: String,
pub(crate) day_key: i64,
pub(crate) progress_count: u32,
pub(crate) threshold: u32,
pub(crate) status: RuntimeProfileTaskStatus,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_task_reward_claim,
index(accessor = by_profile_task_claim_user, btree(columns = [user_id])),
index(
accessor = by_profile_task_claim_user_task,
btree(columns = [user_id, task_id])
)
)]
pub struct ProfileTaskRewardClaim {
#[primary_key]
pub(crate) claim_id: String,
pub(crate) user_id: String,
pub(crate) task_id: String,
pub(crate) day_key: i64,
pub(crate) reward_points: u64,
pub(crate) wallet_ledger_id: String,
pub(crate) claimed_at: Timestamp,
}
#[spacetimedb::table(accessor = profile_redeem_code)] #[spacetimedb::table(accessor = profile_redeem_code)]
pub struct ProfileRedeemCode { pub struct ProfileRedeemCode {
#[primary_key] #[primary_key]
@@ -355,6 +468,103 @@ pub fn list_profile_wallet_ledger(
} }
} }
// 任务中心读取会顺手记录当日登录埋点,确保“每日登录”只依赖后端事实。
#[spacetimedb::procedure]
pub fn get_profile_task_center(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskCenterGetInput,
) -> RuntimeProfileTaskCenterProcedureResult {
match ctx.try_with_tx(|tx| get_profile_task_center_snapshot(tx, input.clone(), true)) {
Ok(record) => RuntimeProfileTaskCenterProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileTaskCenterProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 领奖记录与光点流水在同一事务内写入,避免任务状态和钱包余额漂移。
#[spacetimedb::procedure]
pub fn claim_profile_task_reward_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskClaimInput,
) -> RuntimeProfileTaskClaimProcedureResult {
match ctx.try_with_tx(|tx| claim_profile_task_reward_record(tx, input.clone())) {
Ok(record) => RuntimeProfileTaskClaimProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileTaskClaimProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_list_profile_task_configs(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskConfigAdminListInput,
) -> RuntimeProfileTaskConfigAdminListProcedureResult {
match ctx.try_with_tx(|tx| list_profile_task_config_snapshots(tx, input.clone())) {
Ok(entries) => RuntimeProfileTaskConfigAdminListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileTaskConfigAdminListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_upsert_profile_task_config(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskConfigAdminUpsertInput,
) -> RuntimeProfileTaskConfigAdminProcedureResult {
match ctx.try_with_tx(|tx| upsert_profile_task_config_record(tx, input.clone())) {
Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileTaskConfigAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_disable_profile_task_config(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskConfigAdminDisableInput,
) -> RuntimeProfileTaskConfigAdminProcedureResult {
match ctx.try_with_tx(|tx| disable_profile_task_config_record(tx, input.clone())) {
Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileTaskConfigAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。 // 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。
#[spacetimedb::procedure] #[spacetimedb::procedure]
pub fn grant_new_user_registration_wallet_reward( pub fn grant_new_user_registration_wallet_reward(
@@ -591,6 +801,25 @@ pub fn admin_disable_profile_redeem_code(
} }
} }
#[spacetimedb::procedure]
pub fn admin_list_profile_redeem_codes(
ctx: &mut ProcedureContext,
input: RuntimeProfileRedeemCodeAdminListInput,
) -> RuntimeProfileRedeemCodeAdminListProcedureResult {
match ctx.try_with_tx(|tx| admin_list_profile_redeem_code_records(tx, input.clone())) {
Ok(entries) => RuntimeProfileRedeemCodeAdminListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileRedeemCodeAdminListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure] #[spacetimedb::procedure]
pub fn admin_upsert_profile_invite_code( pub fn admin_upsert_profile_invite_code(
ctx: &mut ProcedureContext, ctx: &mut ProcedureContext,
@@ -610,6 +839,25 @@ pub fn admin_upsert_profile_invite_code(
} }
} }
#[spacetimedb::procedure]
pub fn admin_list_profile_invite_codes(
ctx: &mut ProcedureContext,
input: RuntimeProfileInviteCodeAdminListInput,
) -> RuntimeProfileInviteCodeAdminListProcedureResult {
match ctx.try_with_tx(|tx| admin_list_profile_invite_code_records(tx, input.clone())) {
Ok(entries) => RuntimeProfileInviteCodeAdminListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileInviteCodeAdminListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
pub(crate) fn list_profile_save_archive_rows( pub(crate) fn list_profile_save_archive_rows(
ctx: &ReducerContext, ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveListInput, input: RuntimeProfileSaveArchiveListInput,
@@ -2136,6 +2384,533 @@ fn build_profile_recharge_center_snapshot(
} }
} }
fn get_profile_task_center_snapshot(
ctx: &ReducerContext,
input: RuntimeProfileTaskCenterGetInput,
record_login_event: bool,
) -> Result<RuntimeProfileTaskCenterSnapshot, String> {
let validated_input = build_runtime_profile_task_center_get_input(input.user_id)
.map_err(|error| error.to_string())?;
ensure_default_profile_task_config(ctx);
if record_login_event {
record_daily_login_tracking_event(ctx, &validated_input.user_id)?;
}
Ok(build_profile_task_center_snapshot(
ctx,
&validated_input.user_id,
ctx.timestamp,
))
}
fn claim_profile_task_reward_record(
ctx: &ReducerContext,
input: RuntimeProfileTaskClaimInput,
) -> Result<RuntimeProfileTaskClaimSnapshot, String> {
let validated_input = build_runtime_profile_task_claim_input(input.user_id, input.task_id)
.map_err(|error| error.to_string())?;
ensure_default_profile_task_config(ctx);
let config = ctx
.db
.profile_task_config()
.task_id()
.find(&validated_input.task_id)
.ok_or_else(|| RuntimeProfileFieldError::MissingTaskId.to_string())?;
if !config.enabled {
return Err(RuntimeProfileFieldError::TaskDisabled.to_string());
}
if is_daily_login_task_config(&config) {
record_daily_login_tracking_event(ctx, &validated_input.user_id)?;
}
let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch());
let claim_id =
build_runtime_profile_task_claim_id(&validated_input.user_id, &config.task_id, day_key);
if ctx
.db
.profile_task_reward_claim()
.claim_id()
.find(&claim_id)
.is_some()
{
return Err(RuntimeProfileFieldError::TaskAlreadyClaimed.to_string());
}
let progress_count = profile_task_progress_count(ctx, &validated_input.user_id, &config);
if progress_count < config.threshold {
return Err(RuntimeProfileFieldError::TaskNotClaimable.to_string());
}
let ledger_id = build_runtime_profile_task_reward_ledger_id(
&validated_input.user_id,
&config.task_id,
day_key,
);
let wallet_balance = grant_profile_wallet_points(
ctx,
&validated_input.user_id,
config.reward_points,
RuntimeProfileWalletLedgerSourceType::DailyTaskReward,
&ledger_id,
ctx.timestamp,
)?;
let claim = ctx
.db
.profile_task_reward_claim()
.insert(ProfileTaskRewardClaim {
claim_id: claim_id.clone(),
user_id: validated_input.user_id.clone(),
task_id: config.task_id.clone(),
day_key,
reward_points: config.reward_points,
wallet_ledger_id: ledger_id.clone(),
claimed_at: ctx.timestamp,
});
refresh_profile_task_progress(ctx, &validated_input.user_id, &config, day_key);
let ledger_entry = ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&ledger_id)
.ok_or_else(|| "任务奖励钱包流水写入失败".to_string())?;
Ok(RuntimeProfileTaskClaimSnapshot {
user_id: validated_input.user_id.clone(),
task_id: config.task_id.clone(),
day_key,
reward_points: claim.reward_points,
wallet_balance,
ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry),
center: build_profile_task_center_snapshot(ctx, &validated_input.user_id, ctx.timestamp),
})
}
fn list_profile_task_config_snapshots(
ctx: &ReducerContext,
input: RuntimeProfileTaskConfigAdminListInput,
) -> Result<Vec<RuntimeProfileTaskConfigSnapshot>, String> {
let _validated_input = build_runtime_profile_task_config_admin_list_input(input.admin_user_id)
.map_err(|error| error.to_string())?;
ensure_default_profile_task_config(ctx);
let mut entries = ctx
.db
.profile_task_config()
.iter()
.map(|row| build_profile_task_config_snapshot_from_row(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
left.sort_order
.cmp(&right.sort_order)
.then_with(|| left.task_id.cmp(&right.task_id))
});
Ok(entries)
}
fn admin_list_profile_redeem_code_records(
ctx: &ReducerContext,
input: RuntimeProfileRedeemCodeAdminListInput,
) -> Result<Vec<RuntimeProfileRedeemCodeSnapshot>, String> {
let _validated_input = build_runtime_profile_redeem_code_admin_list_input(input.admin_user_id)
.map_err(|error| error.to_string())?;
let mut entries = ctx
.db
.profile_redeem_code()
.iter()
.map(|row| build_profile_redeem_code_snapshot_from_row(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.code.cmp(&right.code))
});
Ok(entries)
}
fn admin_list_profile_invite_code_records(
ctx: &ReducerContext,
input: RuntimeProfileInviteCodeAdminListInput,
) -> Result<Vec<RuntimeProfileInviteCodeSnapshot>, String> {
let _validated_input = build_runtime_profile_invite_code_admin_list_input(input.admin_user_id)
.map_err(|error| error.to_string())?;
let mut entries = ctx
.db
.profile_invite_code()
.iter()
.filter(|row| is_admin_profile_invite_code_user_id(&row.user_id))
.map(|row| build_profile_invite_code_snapshot_from_row(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.invite_code.cmp(&right.invite_code))
});
Ok(entries)
}
fn upsert_profile_task_config_record(
ctx: &ReducerContext,
input: RuntimeProfileTaskConfigAdminUpsertInput,
) -> Result<RuntimeProfileTaskConfigSnapshot, String> {
let validated_input = build_runtime_profile_task_config_admin_upsert_input(
input.admin_user_id,
input.task_id,
input.title,
input.description,
input.event_key,
input.cycle,
input.scope_kind,
input.threshold,
input.reward_points,
input.enabled,
input.sort_order,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
let existing = ctx
.db
.profile_task_config()
.task_id()
.find(&validated_input.task_id);
if let Some(row) = existing.as_ref() {
ctx.db.profile_task_config().task_id().delete(&row.task_id);
}
let inserted = ctx.db.profile_task_config().insert(ProfileTaskConfig {
task_id: validated_input.task_id,
title: validated_input.title,
description: validated_input.description,
event_key: validated_input.event_key,
cycle: validated_input.cycle,
scope_kind: validated_input.scope_kind,
threshold: validated_input.threshold,
reward_points: validated_input.reward_points,
enabled: validated_input.enabled,
sort_order: validated_input.sort_order,
created_by: existing
.as_ref()
.map(|row| row.created_by.clone())
.unwrap_or_else(|| validated_input.admin_user_id.clone()),
created_at: existing
.as_ref()
.map(|row| row.created_at)
.unwrap_or(updated_at),
updated_by: validated_input.admin_user_id,
updated_at,
});
Ok(build_profile_task_config_snapshot_from_row(&inserted))
}
fn disable_profile_task_config_record(
ctx: &ReducerContext,
input: RuntimeProfileTaskConfigAdminDisableInput,
) -> Result<RuntimeProfileTaskConfigSnapshot, String> {
let validated_input = build_runtime_profile_task_config_admin_disable_input(
input.admin_user_id,
input.task_id,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let row = ctx
.db
.profile_task_config()
.task_id()
.find(&validated_input.task_id)
.ok_or_else(|| RuntimeProfileFieldError::MissingTaskId.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
ctx.db.profile_task_config().task_id().delete(&row.task_id);
let inserted = ctx.db.profile_task_config().insert(ProfileTaskConfig {
enabled: false,
updated_by: validated_input.admin_user_id,
updated_at,
..row
});
Ok(build_profile_task_config_snapshot_from_row(&inserted))
}
fn build_profile_task_center_snapshot(
ctx: &ReducerContext,
user_id: &str,
updated_at: Timestamp,
) -> RuntimeProfileTaskCenterSnapshot {
ensure_default_profile_task_config(ctx);
let day_key = runtime_profile_beijing_day_key(updated_at.to_micros_since_unix_epoch());
let mut configs = ctx.db.profile_task_config().iter().collect::<Vec<_>>();
configs.sort_by(|left, right| {
left.sort_order
.cmp(&right.sort_order)
.then_with(|| left.task_id.cmp(&right.task_id))
});
let tasks = configs
.into_iter()
.map(|config| {
let progress_count = profile_task_progress_count(ctx, user_id, &config);
refresh_profile_task_progress(ctx, user_id, &config, day_key);
let claim = ctx.db.profile_task_reward_claim().claim_id().find(
&build_runtime_profile_task_claim_id(user_id, &config.task_id, day_key),
);
RuntimeProfileTaskItemSnapshot {
task_id: config.task_id,
title: config.title,
description: config.description,
event_key: config.event_key,
cycle: config.cycle,
threshold: config.threshold,
progress_count,
reward_points: config.reward_points,
status: resolve_runtime_profile_task_status(
config.enabled,
progress_count,
config.threshold,
claim.is_some(),
),
day_key,
claimed_at_micros: claim.map(|row| row.claimed_at.to_micros_since_unix_epoch()),
updated_at_micros: updated_at.to_micros_since_unix_epoch(),
}
})
.collect();
RuntimeProfileTaskCenterSnapshot {
user_id: user_id.to_string(),
day_key,
wallet_balance: profile_wallet_balance(ctx, user_id),
tasks,
updated_at_micros: updated_at.to_micros_since_unix_epoch(),
}
}
fn refresh_profile_task_progress(
ctx: &ReducerContext,
user_id: &str,
config: &ProfileTaskConfig,
day_key: i64,
) -> ProfileTaskProgress {
let progress_id = build_runtime_profile_task_progress_id(user_id, &config.task_id, day_key);
if let Some(existing) = ctx
.db
.profile_task_progress()
.progress_id()
.find(&progress_id)
{
ctx.db
.profile_task_progress()
.progress_id()
.delete(&existing.progress_id);
}
let progress_count = profile_task_progress_count(ctx, user_id, config);
let claimed = ctx
.db
.profile_task_reward_claim()
.claim_id()
.find(&build_runtime_profile_task_claim_id(
user_id,
&config.task_id,
day_key,
))
.is_some();
ctx.db.profile_task_progress().insert(ProfileTaskProgress {
progress_id,
user_id: user_id.to_string(),
task_id: config.task_id.clone(),
day_key,
progress_count,
threshold: config.threshold,
status: resolve_runtime_profile_task_status(
config.enabled,
progress_count,
config.threshold,
claimed,
),
updated_at: ctx.timestamp,
})
}
fn profile_task_progress_count(
ctx: &ReducerContext,
user_id: &str,
config: &ProfileTaskConfig,
) -> u32 {
let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch());
let scope_id = profile_task_tracking_scope_id(user_id, config);
ctx.db
.tracking_daily_stat()
.stat_id()
.find(&build_runtime_tracking_daily_stat_id(
&config.event_key,
config.scope_kind,
&scope_id,
day_key,
))
.map(|row| row.count)
.unwrap_or(0)
}
fn profile_task_tracking_scope_id(user_id: &str, config: &ProfileTaskConfig) -> String {
match config.scope_kind {
RuntimeTrackingScopeKind::Site => PROFILE_TRACKING_SITE_SCOPE_ID.to_string(),
RuntimeTrackingScopeKind::Module => PROFILE_TRACKING_PROFILE_MODULE_KEY.to_string(),
RuntimeTrackingScopeKind::User => user_id.to_string(),
RuntimeTrackingScopeKind::Work => user_id.to_string(),
}
}
fn is_daily_login_task_config(config: &ProfileTaskConfig) -> bool {
config.task_id == PROFILE_TASK_ID_DAILY_LOGIN
&& config.event_key == PROFILE_TASK_EVENT_KEY_DAILY_LOGIN
&& config.scope_kind == RuntimeTrackingScopeKind::User
}
fn record_daily_login_tracking_event(ctx: &ReducerContext, user_id: &str) -> Result<(), String> {
let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch());
let event_id = format!(
"{}:{}:{}",
PROFILE_TASK_LOGIN_EVENT_ID_PREFIX,
user_id.trim(),
day_key
);
if ctx.db.tracking_event().event_id().find(&event_id).is_some() {
return Ok(());
}
record_tracking_event(
ctx,
RuntimeTrackingEventInput {
event_id,
event_key: PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
scope_kind: RuntimeTrackingScopeKind::User,
scope_id: user_id.to_string(),
user_id: Some(user_id.to_string()),
owner_user_id: None,
profile_id: None,
module_key: Some(PROFILE_TRACKING_PROFILE_MODULE_KEY.to_string()),
metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(),
occurred_at_micros: ctx.timestamp.to_micros_since_unix_epoch(),
},
)
}
fn record_tracking_event(
ctx: &ReducerContext,
input: RuntimeTrackingEventInput,
) -> Result<(), String> {
let validated_input = build_runtime_tracking_event_input(
input.event_id,
input.event_key,
input.scope_kind,
input.scope_id,
input.user_id,
input.owner_user_id,
input.profile_id,
input.module_key,
input.metadata_json,
input.occurred_at_micros,
)
.map_err(|error| error.to_string())?;
let occurred_at = Timestamp::from_micros_since_unix_epoch(validated_input.occurred_at_micros);
let day_key = runtime_profile_beijing_day_key(validated_input.occurred_at_micros);
ctx.db.tracking_event().insert(TrackingEvent {
event_id: validated_input.event_id,
event_key: validated_input.event_key.clone(),
scope_kind: validated_input.scope_kind,
scope_id: validated_input.scope_id.clone(),
day_key,
user_id: validated_input.user_id,
owner_user_id: validated_input.owner_user_id,
profile_id: validated_input.profile_id,
module_key: validated_input.module_key,
metadata_json: validated_input.metadata_json,
occurred_at,
});
upsert_tracking_daily_stat(
ctx,
&validated_input.event_key,
validated_input.scope_kind,
&validated_input.scope_id,
day_key,
occurred_at,
);
Ok(())
}
fn upsert_tracking_daily_stat(
ctx: &ReducerContext,
event_key: &str,
scope_kind: RuntimeTrackingScopeKind,
scope_id: &str,
day_key: i64,
occurred_at: Timestamp,
) {
let stat_id = build_runtime_tracking_daily_stat_id(event_key, scope_kind, scope_id, day_key);
let existing = ctx.db.tracking_daily_stat().stat_id().find(&stat_id);
if let Some(row) = existing {
ctx.db.tracking_daily_stat().stat_id().delete(&row.stat_id);
ctx.db.tracking_daily_stat().insert(TrackingDailyStat {
stat_id,
event_key: row.event_key,
scope_kind: row.scope_kind,
scope_id: row.scope_id,
day_key: row.day_key,
count: row.count.saturating_add(1),
first_occurred_at: row.first_occurred_at,
last_occurred_at: occurred_at,
updated_at: occurred_at,
});
} else {
ctx.db.tracking_daily_stat().insert(TrackingDailyStat {
stat_id,
event_key: event_key.to_string(),
scope_kind,
scope_id: scope_id.to_string(),
day_key,
count: 1,
first_occurred_at: occurred_at,
last_occurred_at: occurred_at,
updated_at: occurred_at,
});
}
}
fn ensure_default_profile_task_config(ctx: &ReducerContext) -> ProfileTaskConfig {
if let Some(row) = ctx
.db
.profile_task_config()
.task_id()
.find(&PROFILE_TASK_ID_DAILY_LOGIN.to_string())
{
return row;
}
let default_config = build_default_runtime_profile_task_config(
ctx.timestamp.to_micros_since_unix_epoch(),
PROFILE_TASK_SYSTEM_USER_ID.to_string(),
);
ctx.db.profile_task_config().insert(ProfileTaskConfig {
task_id: default_config.task_id,
title: default_config.title,
description: default_config.description,
event_key: default_config.event_key,
cycle: default_config.cycle,
scope_kind: default_config.scope_kind,
threshold: default_config.threshold,
reward_points: default_config.reward_points,
enabled: default_config.enabled,
sort_order: default_config.sort_order,
created_by: default_config.created_by,
created_at: ctx.timestamp,
updated_by: default_config.updated_by,
updated_at: ctx.timestamp,
})
}
fn build_profile_membership_snapshot( fn build_profile_membership_snapshot(
ctx: &ReducerContext, ctx: &ReducerContext,
user_id: &str, user_id: &str,
@@ -2485,6 +3260,27 @@ fn build_profile_wallet_ledger_snapshot_from_row(
} }
} }
fn build_profile_task_config_snapshot_from_row(
row: &ProfileTaskConfig,
) -> RuntimeProfileTaskConfigSnapshot {
RuntimeProfileTaskConfigSnapshot {
task_id: row.task_id.clone(),
title: row.title.clone(),
description: row.description.clone(),
event_key: row.event_key.clone(),
cycle: row.cycle,
scope_kind: row.scope_kind,
threshold: row.threshold,
reward_points: row.reward_points,
enabled: row.enabled,
sort_order: row.sort_order,
created_by: row.created_by.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_by: row.updated_by.clone(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_recharge_order_snapshot_from_row( fn build_profile_recharge_order_snapshot_from_row(
row: &ProfileRechargeOrder, row: &ProfileRechargeOrder,
) -> RuntimeProfileRechargeOrderSnapshot { ) -> RuntimeProfileRechargeOrderSnapshot {

View File

@@ -12,7 +12,13 @@ import {
} from './services/match3d-runtime'; } from './services/match3d-runtime';
function buildInitialRun() { function buildInitialRun() {
return startLocalMatch3DRun(12); const params = new URLSearchParams(window.location.search);
const clearCountParam = params.get('clearCount') ?? params.get('count');
const clearCount =
clearCountParam === null ? 12 : Number.parseInt(clearCountParam, 10);
return startLocalMatch3DRun(
Number.isFinite(clearCount) && clearCount > 0 ? clearCount : 12,
);
} }
export default function Match3DPlaygroundApp() { export default function Match3DPlaygroundApp() {

View File

@@ -8,7 +8,11 @@ import {
isItemState, isItemState,
resolveRenderableItemFrame, resolveRenderableItemFrame,
} from './match3dRuntimePresentation'; } from './match3dRuntimePresentation';
import { resolveGeometryAsset } from './match3dVisualAssets'; import {
resolveGeometryAsset,
type Match3DGeometryAsset,
type Match3DGeometryShape,
} from './match3dVisualAssets';
type Match3DPhysicsBoardProps = { type Match3DPhysicsBoardProps = {
run: Match3DRunSnapshot; run: Match3DRunSnapshot;
@@ -21,15 +25,17 @@ type ThreeModule = typeof import('three');
type CannonModule = typeof import('cannon-es'); type CannonModule = typeof import('cannon-es');
type PhysicsBody = import('cannon-es').Body; type PhysicsBody = import('cannon-es').Body;
type PhysicsWorld = import('cannon-es').World; type PhysicsWorld = import('cannon-es').World;
type ThreeMesh = import('three').Mesh; type ThreeObject3D = import('three').Object3D;
type ThreeScene = import('three').Scene; type ThreeScene = import('three').Scene;
type ThreeRenderer = import('three').WebGLRenderer; type ThreeRenderer = import('three').WebGLRenderer;
type ThreeCamera = import('three').PerspectiveCamera; type ThreeCamera = import('three').OrthographicCamera;
type PhysicsEntry = { type PhysicsEntry = {
item: Match3DItemSnapshot; item: Match3DItemSnapshot;
body: PhysicsBody; body: PhysicsBody;
mesh: ThreeMesh; lockReadableTop: boolean;
mesh: ThreeObject3D;
topRotationY: number;
}; };
type PhysicsRuntime = { type PhysicsRuntime = {
@@ -48,11 +54,19 @@ const MATCH3D_POT_FLOOR_RADIUS = 4.75;
const MATCH3D_POT_INNER_RADIUS = 4.52; const MATCH3D_POT_INNER_RADIUS = 4.52;
const MATCH3D_POT_OUTER_RADIUS = 5.18; const MATCH3D_POT_OUTER_RADIUS = 5.18;
const MATCH3D_POT_WALL_HEIGHT = 2.15; const MATCH3D_POT_WALL_HEIGHT = 2.15;
const MATCH3D_ITEM_ACTIVITY_RADIUS = 3.82; const MATCH3D_ITEM_ACTIVITY_RADIUS = 3.58;
const MATCH3D_ITEM_POSITION_RADIUS = 3.64; const MATCH3D_ITEM_POSITION_RADIUS = 3.34;
const MATCH3D_ITEM_SPAWN_HEIGHT = 1.85; const MATCH3D_ITEM_SPAWN_HEIGHT = 1.25;
const MATCH3D_ITEM_STACK_HEIGHT_STEP = 0.024;
const MATCH3D_CENTER_GRAVITY_COEFFICIENT = 0;
const MATCH3D_BOARD_CENTER = 0.5; const MATCH3D_BOARD_CENTER = 0.5;
const MATCH3D_PHYSICS_STEP = 1 / 60; const MATCH3D_PHYSICS_STEP = 1 / 60;
const MATCH3D_CAMERA_HALF_SIZE = 6.15;
export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape> =
new Set([
'ring',
'arch',
]);
function hasWebGLSupport() { function hasWebGLSupport() {
try { try {
@@ -67,7 +81,7 @@ function hasWebGLSupport() {
function toWorldPosition(item: Match3DItemSnapshot) { function toWorldPosition(item: Match3DItemSnapshot) {
const frame = resolveRenderableItemFrame(item); const frame = resolveRenderableItemFrame(item);
const radius = Math.max(0.32, frame.radius * MATCH3D_POT_FLOOR_RADIUS * 1.32); const radius = Math.max(0.28, frame.radius * MATCH3D_POT_FLOOR_RADIUS * 1.02);
let x = (frame.x - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2; let x = (frame.x - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2;
let z = (frame.y - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2; let z = (frame.y - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2;
const horizontalDistance = Math.hypot(x, z); const horizontalDistance = Math.hypot(x, z);
@@ -112,92 +126,330 @@ function constrainBodyInsidePot(entry: PhysicsEntry) {
} }
} }
function applyCenterGravity(entry: PhysicsEntry) {
if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) {
return;
}
const horizontalDistance = Math.hypot(
entry.body.position.x,
entry.body.position.z,
);
if (horizontalDistance <= 0.08) {
return;
}
const visualRadius = toWorldPosition(entry.item).radius;
const maxDistance = Math.max(
0.1,
MATCH3D_ITEM_ACTIVITY_RADIUS - visualRadius * 1.05,
);
const edgePressure = Math.min(1, horizontalDistance / maxDistance);
const centerFalloff = Math.min(1, Math.max(0, (horizontalDistance - 1.15) / maxDistance));
const forceStrength =
MATCH3D_CENTER_GRAVITY_COEFFICIENT *
entry.body.mass *
(10.5 + edgePressure * 13) *
centerFalloff;
// 中文注释:中心引力只拉水平面,垂直方向仍交给锅底重力和物体堆叠处理。
entry.body.force.x +=
(-entry.body.position.x / horizontalDistance) * forceStrength;
entry.body.force.z +=
(-entry.body.position.z / horizontalDistance) * forceStrength;
}
function createCannonShape( function createCannonShape(
cannon: CannonModule, cannon: CannonModule,
shape: ReturnType<typeof resolveGeometryAsset>['shape'], shape: ReturnType<typeof resolveGeometryAsset>['shape'],
radius: number, radius: number,
) { ) {
switch (shape) { switch (shape) {
case 'circle': case 'ring':
case 'heart': case 'cylinder':
return new cannon.Sphere(radius); case 'cone':
case 'square': return new cannon.Cylinder(radius * 0.82, radius * 0.82, radius * 1.1, 18);
return new cannon.Box(new cannon.Vec3(radius, radius, radius)); case 'slope':
case 'triangle': return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.72, radius * 0.66));
return new cannon.Cylinder(radius * 0.55, radius, radius * 1.5, 3); case 'arch':
case 'diamond': return new cannon.Box(new cannon.Vec3(radius * 1.35, radius * 0.92, radius * 0.56));
return new cannon.Sphere(radius * 0.92); case 'tile':
case 'star': return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.36, radius * 0.72));
return new cannon.Sphere(radius * 0.88); case 'brick':
case 'hexagon':
return new cannon.Cylinder(radius, radius, radius * 1.2, 6);
case 'capsule':
return new cannon.Box(new cannon.Vec3(radius * 1.28, radius * 0.68, radius * 0.68));
case 'trapezoid':
return new cannon.Box(new cannon.Vec3(radius * 1.02, radius * 0.78, radius * 0.78));
case 'parallelogram':
return new cannon.Box(new cannon.Vec3(radius * 1.12, radius * 0.72, radius * 0.72));
default: default:
return new cannon.Sphere(radius); return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.72, radius * 0.72));
} }
} }
function createThreeGeometry( function buildPointShape(
three: ThreeModule, three: ThreeModule,
shape: ReturnType<typeof resolveGeometryAsset>['shape'], radius: number,
points: Array<[number, number]>,
) {
const shape = new three.Shape();
points.forEach(([x, y], index) => {
if (index === 0) {
shape.moveTo(x * radius, y * radius);
} else {
shape.lineTo(x * radius, y * radius);
}
});
shape.closePath();
return shape;
}
function buildRingShape(three: ThreeModule, radius: number) {
const shape = new three.Shape();
shape.absarc(0, 0, radius * 0.92, 0, Math.PI * 2, false);
const hole = new three.Path();
hole.absarc(0, 0, radius * 0.43, 0, Math.PI * 2, true);
shape.holes.push(hole);
return shape;
}
function buildReadableShape(
three: ThreeModule,
shape: Match3DGeometryShape,
radius: number, radius: number,
) { ) {
switch (shape) { switch (shape) {
case 'circle': case 'ring':
return new three.SphereGeometry(radius, 28, 18); return buildRingShape(three, radius);
case 'square': case 'arch':
return new three.BoxGeometry(radius * 1.65, radius * 1.65, radius * 1.65); return buildPointShape(three, radius, [
case 'triangle': [-1, 0.8],
return new three.ConeGeometry(radius, radius * 1.9, 3); [1, 0.8],
case 'diamond': [1, -0.7],
return new three.OctahedronGeometry(radius * 1.04, 1); [0.42, -0.7],
case 'star': [0.42, 0.24],
return new three.IcosahedronGeometry(radius * 0.96, 0); [-0.42, 0.24],
case 'hexagon': [-0.42, -0.7],
return new three.CylinderGeometry(radius, radius, radius * 1.35, 6); [-1, -0.7],
case 'capsule': ]);
return new three.CapsuleGeometry(radius * 0.62, radius * 1.18, 6, 14);
case 'heart':
return new three.SphereGeometry(radius, 24, 16);
case 'trapezoid':
return new three.CylinderGeometry(radius * 0.78, radius * 1.12, radius * 1.1, 4);
case 'parallelogram':
return new three.BoxGeometry(radius * 1.9, radius * 1.05, radius * 1.05);
default: default:
return new three.SphereGeometry(radius, 28, 18); return null;
} }
} }
function createExtrudedReadableGeometry(
three: ThreeModule,
shape: Match3DGeometryShape,
radius: number,
) {
const path = buildReadableShape(three, shape, radius);
if (!path) {
return null;
}
const geometry = new three.ExtrudeGeometry(path, {
bevelEnabled: true,
bevelSegments: 2,
bevelSize: radius * 0.045,
bevelThickness: radius * 0.04,
depth: radius * 0.42,
steps: 1,
});
geometry.center();
geometry.rotateX(-Math.PI / 2);
return geometry;
}
export function createMatch3DThreeGeometry(
three: ThreeModule,
shape: Match3DGeometryShape,
radius: number,
) {
const readableGeometry = createExtrudedReadableGeometry(three, shape, radius);
if (readableGeometry) {
return readableGeometry;
}
switch (shape) {
case 'cylinder':
return new three.CylinderGeometry(radius * 0.72, radius * 0.72, radius * 1.35, 26);
case 'cone':
return new three.ConeGeometry(radius * 0.78, radius * 1.62, 28);
case 'tile':
case 'brick':
case 'slope':
case 'arch':
default:
return new three.BoxGeometry(radius * 1.8, radius * 0.9, radius * 1.2);
}
}
function createRoundedBlockBase(
three: ThreeModule,
asset: Match3DGeometryAsset,
radius: number,
) {
const width = radius * (0.9 + asset.studsX * 0.62);
const depth = radius * (0.9 + asset.studsY * 0.62);
const height = Math.max(radius * 0.24, radius * asset.heightScale);
return new three.BoxGeometry(width, height, depth);
}
function createStudGeometry(three: ThreeModule, radius: number) {
return new three.CylinderGeometry(radius * 0.18, radius * 0.18, radius * 0.12, 20);
}
function createSlopeGeometry(
three: ThreeModule,
asset: Match3DGeometryAsset,
radius: number,
) {
const width = radius * (1 + asset.studsX * 0.66);
const depth = radius * (0.95 + asset.studsY * 0.62);
const height = radius * asset.heightScale;
const halfW = width / 2;
const halfD = depth / 2;
const halfH = height / 2;
const vertices = new Float32Array([
-halfW, -halfH, -halfD,
halfW, -halfH, -halfD,
halfW, -halfH, halfD,
-halfW, -halfH, halfD,
halfW, halfH, -halfD,
halfW, halfH, halfD,
]);
const indices = [
0, 1, 2, 0, 2, 3,
1, 4, 5, 1, 5, 2,
3, 2, 5, 3, 5, 0,
0, 5, 4, 0, 4, 1,
];
const geometry = new three.BufferGeometry();
geometry.setAttribute('position', new three.BufferAttribute(vertices, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
return geometry;
}
function addBrickStuds(
three: ThreeModule,
group: import('three').Group,
asset: Match3DGeometryAsset,
radius: number,
material: import('three').Material,
) {
if (asset.shape === 'tile') {
return;
}
const studGeometry = createStudGeometry(three, radius);
const width = radius * (0.9 + asset.studsX * 0.62);
const depth = radius * (0.9 + asset.studsY * 0.62);
const y = Math.max(radius * 0.24, radius * asset.heightScale) / 2 + radius * 0.06;
for (let row = 0; row < asset.studsY; row += 1) {
for (let column = 0; column < asset.studsX; column += 1) {
const stud = new three.Mesh(studGeometry.clone(), material);
stud.position.set(
((column + 0.5) / asset.studsX - 0.5) * width * 0.74,
y,
((row + 0.5) / asset.studsY - 0.5) * depth * 0.72,
);
group.add(stud);
}
}
}
function createBlockMesh(
three: ThreeModule,
asset: Match3DGeometryAsset,
radius: number,
material: import('three').Material,
) {
const group = new three.Group();
let baseGeometry: import('three').BufferGeometry;
if (asset.shape === 'slope') {
baseGeometry = createSlopeGeometry(three, asset, radius);
} else if (asset.shape === 'cylinder') {
baseGeometry = new three.CylinderGeometry(radius * 0.58, radius * 0.58, radius * 1.18, 28);
} else if (asset.shape === 'cone') {
baseGeometry = new three.ConeGeometry(radius * 0.68, radius * 1.48, 30);
} else if (MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape)) {
baseGeometry = createMatch3DThreeGeometry(three, asset.shape, radius);
} else {
baseGeometry = createRoundedBlockBase(three, asset, radius);
}
const base = new three.Mesh(baseGeometry, material);
group.add(base);
if (asset.shape === 'brick' || asset.shape === 'slope') {
addBrickStuds(three, group, asset, radius, material);
}
if (asset.shape === 'cylinder') {
const topStud = new three.Mesh(createStudGeometry(three, radius * 1.2), material);
topStud.position.y = radius * 0.65;
group.add(topStud);
}
if (asset.shape === 'cone') {
const lip = new three.Mesh(
new three.TorusGeometry(radius * 0.38, radius * 0.07, 8, 24),
material,
);
lip.rotation.x = Math.PI / 2;
lip.position.y = radius * 0.52;
group.add(lip);
}
return group;
}
function markObjectForItem(object: ThreeObject3D, itemInstanceId: string) {
object.userData.itemInstanceId = itemInstanceId;
object.traverse((child) => {
child.userData.itemInstanceId = itemInstanceId;
child.castShadow = true;
child.receiveShadow = true;
});
}
function disposeThreeObject(object: ThreeObject3D) {
object.traverse((child) => {
const maybeMesh = child as import('three').Mesh;
maybeMesh.geometry?.dispose();
const material = maybeMesh.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material?.dispose();
}
});
}
export function createMatch3DItemMesh(
three: ThreeModule,
item: Match3DItemSnapshot,
) {
const asset = resolveGeometryAsset(item.visualKey);
const position = toWorldPosition(item);
const material = new three.MeshStandardMaterial({
color: asset.fill,
emissive: asset.fill,
emissiveIntensity: 0.08,
metalness: 0.16,
opacity: asset.transparent ? 0.58 : 1,
roughness: 0.46,
transparent: Boolean(asset.transparent),
side: MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape)
? three.DoubleSide
: three.FrontSide,
});
const mesh = createBlockMesh(three, asset, position.radius, material);
markObjectForItem(mesh, item.itemInstanceId);
return {
lockReadableTop: MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape),
mesh,
radius: position.radius,
shape: asset.shape,
topRotationY: ((item.layer % 12) / 12) * Math.PI * 2,
position,
};
}
function createItemMesh( function createItemMesh(
three: ThreeModule, three: ThreeModule,
item: Match3DItemSnapshot, item: Match3DItemSnapshot,
) { ) {
const asset = resolveGeometryAsset(item.visualKey); return createMatch3DItemMesh(three, item);
const position = toWorldPosition(item);
const geometry = createThreeGeometry(three, asset.shape, position.radius);
if (asset.shape === 'parallelogram') {
geometry.applyMatrix4(new three.Matrix4().makeShear(0.28, 0, 0, 0, 0, 0));
}
if (asset.shape === 'heart') {
geometry.scale(1, 0.92, 0.82);
}
const material = new three.MeshStandardMaterial({
color: asset.fill,
emissive: asset.fill,
emissiveIntensity: 0.08,
metalness: 0.16,
roughness: 0.46,
});
const mesh = new three.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData.itemInstanceId = item.itemInstanceId;
return { mesh, shape: asset.shape, radius: position.radius, position };
} }
function disposeRuntime(runtime: PhysicsRuntime | null) { function disposeRuntime(runtime: PhysicsRuntime | null) {
@@ -208,18 +460,182 @@ function disposeRuntime(runtime: PhysicsRuntime | null) {
window.cancelAnimationFrame(runtime.animationId); window.cancelAnimationFrame(runtime.animationId);
} }
runtime.entries.forEach((entry) => { runtime.entries.forEach((entry) => {
entry.mesh.geometry.dispose(); disposeThreeObject(entry.mesh);
const material = entry.mesh.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material.dispose();
}
}); });
runtime.renderer.dispose(); runtime.renderer.dispose();
runtime.renderer.domElement.remove(); runtime.renderer.domElement.remove();
} }
type TrayPreviewRuntime = {
animationId: number | null;
camera: ThreeCamera;
entries: Map<string, ThreeObject3D>;
renderer: ThreeRenderer;
scene: ThreeScene;
three: ThreeModule;
};
function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
if (!runtime) {
return;
}
if (runtime.animationId !== null) {
window.cancelAnimationFrame(runtime.animationId);
}
runtime.entries.forEach((mesh) => {
disposeThreeObject(mesh);
});
runtime.entries.clear();
runtime.renderer.dispose();
runtime.renderer.domElement.remove();
}
export function Match3DTrayPreviewBoard({
slotItems,
}: {
slotItems: Array<Match3DItemSnapshot | null>;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<TrayPreviewRuntime | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
let cleanupResize: (() => void) | undefined;
async function setup() {
const container = containerRef.current;
if (!container || !hasWebGLSupport()) {
return;
}
const three = await import('three');
if (cancelled || !containerRef.current) {
return;
}
const renderer = new three.WebGLRenderer({
alpha: true,
antialias: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
renderer.outputColorSpace = three.SRGBColorSpace;
container.appendChild(renderer.domElement);
const scene = new three.Scene();
scene.background = null;
const camera = new three.OrthographicCamera(-3.7, 3.7, 1.1, -1.1, 0.1, 40);
camera.position.set(4.2, 3.2, 4.2);
camera.lookAt(0, 0, 0);
scene.add(new three.AmbientLight(0xffffff, 1.55));
const keyLight = new three.DirectionalLight(0xffffff, 2.2);
keyLight.position.set(-2.6, 4.4, 3.2);
scene.add(keyLight);
const fillLight = new three.DirectionalLight(0xfef3c7, 0.95);
fillLight.position.set(3.2, 2.8, -2.8);
scene.add(fillLight);
const resize = () => {
const rect = container.getBoundingClientRect();
const width = Math.max(1, rect.width);
const height = Math.max(1, rect.height);
renderer.setSize(width, height, false);
renderer.render(scene, camera);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(container);
const animate = () => {
const activeRuntime = runtimeRef.current;
if (!activeRuntime) {
return;
}
renderer.render(scene, camera);
activeRuntime.animationId = window.requestAnimationFrame(animate);
};
runtimeRef.current = {
animationId: window.requestAnimationFrame(animate),
camera,
entries: new Map(),
renderer,
scene,
three,
};
setReady(true);
cleanupResize = () => ro.disconnect();
}
void setup();
return () => {
cancelled = true;
cleanupResize?.();
disposeTrayPreview(runtimeRef.current);
runtimeRef.current = null;
setReady(false);
};
}, []);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
const activeIds = new Set(
slotItems
.filter((item): item is Match3DItemSnapshot => Boolean(item))
.map((item) => item.itemInstanceId),
);
runtime.entries.forEach((mesh, itemInstanceId) => {
if (!activeIds.has(itemInstanceId)) {
runtime.scene.remove(mesh);
disposeThreeObject(mesh);
runtime.entries.delete(itemInstanceId);
}
});
slotItems.forEach((item, slotIndex) => {
if (!item) {
return;
}
let mesh = runtime.entries.get(item.itemInstanceId);
if (!mesh) {
const preview = createMatch3DItemMesh(runtime.three, item);
mesh = preview.mesh;
mesh.rotation.set(-0.12, Math.PI / 4, 0.08);
const bounds = new runtime.three.Box3().setFromObject(mesh);
const size = bounds.getSize(new runtime.three.Vector3());
const maxDimension = Math.max(size.x, size.y, size.z, 0.001);
mesh.scale.multiplyScalar(0.82 / maxDimension);
const centeredBounds = new runtime.three.Box3().setFromObject(mesh);
const center = centeredBounds.getCenter(new runtime.three.Vector3());
mesh.position.sub(center);
runtime.scene.add(mesh);
runtime.entries.set(item.itemInstanceId, mesh);
}
mesh.position.x = (slotIndex - 3) * 1.03;
mesh.position.y = 0;
mesh.position.z = 0;
});
runtime.renderer.render(runtime.scene, runtime.camera);
}, [ready, slotItems]);
return (
<div
ref={containerRef}
className="pointer-events-none absolute inset-0 z-10"
data-testid="match3d-tray-model-board"
/>
);
}
export function Match3DPhysicsBoard({ export function Match3DPhysicsBoard({
run, run,
disabled, disabled,
@@ -272,13 +688,29 @@ export function Match3DPhysicsBoard({
renderer.shadowMap.enabled = true; renderer.shadowMap.enabled = true;
renderer.outputColorSpace = three.SRGBColorSpace; renderer.outputColorSpace = three.SRGBColorSpace;
container.appendChild(renderer.domElement); container.appendChild(renderer.domElement);
const handleContextLost = (event: Event) => {
event.preventDefault();
fallbackRef.current();
};
renderer.domElement.addEventListener(
'webglcontextlost',
handleContextLost,
false,
);
const scene = new three.Scene(); const scene = new three.Scene();
scene.background = null; scene.background = null;
const camera = new three.PerspectiveCamera(32, 1, 0.1, 80); const camera = new three.OrthographicCamera(
camera.position.set(0, 14.8, 2.3); -MATCH3D_CAMERA_HALF_SIZE,
camera.lookAt(0, 0.48, 0); MATCH3D_CAMERA_HALF_SIZE,
MATCH3D_CAMERA_HALF_SIZE,
-MATCH3D_CAMERA_HALF_SIZE,
0.1,
80,
);
camera.position.set(0, 17.5, 0.01);
camera.lookAt(0, 0, 0);
const ambient = new three.AmbientLight(0xffffff, 1.28); const ambient = new three.AmbientLight(0xffffff, 1.28);
scene.add(ambient); scene.add(ambient);
@@ -407,7 +839,10 @@ export function Match3DPhysicsBoard({
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
const size = Math.max(1, Math.min(rect.width, rect.height)); const size = Math.max(1, Math.min(rect.width, rect.height));
renderer.setSize(size, size, false); renderer.setSize(size, size, false);
camera.aspect = 1; camera.left = -MATCH3D_CAMERA_HALF_SIZE;
camera.right = MATCH3D_CAMERA_HALF_SIZE;
camera.top = MATCH3D_CAMERA_HALF_SIZE;
camera.bottom = -MATCH3D_CAMERA_HALF_SIZE;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
}; };
resize(); resize();
@@ -423,9 +858,13 @@ export function Match3DPhysicsBoard({
} }
const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000)); const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000));
lastTime = now; lastTime = now;
activeRuntime.entries.forEach((entry) => {
applyCenterGravity(entry);
});
activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 3); activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 3);
activeRuntime.entries.forEach((entry) => { activeRuntime.entries.forEach((entry) => {
applyCenterGravity(entry);
constrainBodyInsidePot(entry); constrainBodyInsidePot(entry);
entry.mesh.position.set( entry.mesh.position.set(
entry.body.position.x, entry.body.position.x,
@@ -433,11 +872,14 @@ export function Match3DPhysicsBoard({
entry.body.position.z, entry.body.position.z,
); );
entry.mesh.quaternion.set( entry.mesh.quaternion.set(
entry.body.quaternion.x, entry.lockReadableTop ? 0 : entry.body.quaternion.x,
entry.body.quaternion.y, entry.lockReadableTop ? 0 : entry.body.quaternion.y,
entry.body.quaternion.z, entry.lockReadableTop ? 0 : entry.body.quaternion.z,
entry.body.quaternion.w, entry.lockReadableTop ? 1 : entry.body.quaternion.w,
); );
if (entry.lockReadableTop) {
entry.mesh.rotation.y = entry.topRotationY;
}
}); });
activeRuntime.renderer.render(activeRuntime.scene, activeRuntime.camera); activeRuntime.renderer.render(activeRuntime.scene, activeRuntime.camera);
@@ -447,6 +889,11 @@ export function Match3DPhysicsBoard({
setReady(true); setReady(true);
return () => { return () => {
renderer.domElement.removeEventListener(
'webglcontextlost',
handleContextLost,
false,
);
ro.disconnect(); ro.disconnect();
}; };
} catch { } catch {
@@ -475,11 +922,7 @@ export function Match3DPhysicsBoard({
const activeItemIds = new Set( const activeItemIds = new Set(
run.items run.items
.filter( .filter((item) => isItemState(item.state, 'in_board'))
(item) =>
isItemState(item.state, 'in_board') ||
isItemState(item.state, 'flying'),
)
.map((item) => item.itemInstanceId), .map((item) => item.itemInstanceId),
); );
@@ -487,29 +930,20 @@ export function Match3DPhysicsBoard({
if (!activeItemIds.has(itemInstanceId)) { if (!activeItemIds.has(itemInstanceId)) {
runtime.scene.remove(entry.mesh); runtime.scene.remove(entry.mesh);
runtime.world.removeBody(entry.body); runtime.world.removeBody(entry.body);
entry.mesh.geometry.dispose(); disposeThreeObject(entry.mesh);
const material = entry.mesh.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material.dispose();
}
runtime.entries.delete(itemInstanceId); runtime.entries.delete(itemInstanceId);
} }
}); });
run.items.forEach((item) => { run.items.forEach((item) => {
if ( if (!isItemState(item.state, 'in_board')) {
!isItemState(item.state, 'in_board') &&
!isItemState(item.state, 'flying')
) {
return; return;
} }
const existing = runtime.entries.get(item.itemInstanceId); const existing = runtime.entries.get(item.itemInstanceId);
if (existing) { if (existing) {
existing.item = item; existing.item = item;
existing.mesh.visible = isItemState(item.state, 'in_board'); existing.mesh.visible = true;
return; return;
} }
@@ -521,7 +955,7 @@ export function Match3DPhysicsBoard({
shape: createCannonShape(runtime.cannon, visual.shape, visual.radius), shape: createCannonShape(runtime.cannon, visual.shape, visual.radius),
position: new runtime.cannon.Vec3( position: new runtime.cannon.Vec3(
visual.position.x, visual.position.x,
MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * 0.055, MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * MATCH3D_ITEM_STACK_HEIGHT_STEP,
visual.position.z, visual.position.z,
), ),
}); });
@@ -541,7 +975,9 @@ export function Match3DPhysicsBoard({
runtime.entries.set(item.itemInstanceId, { runtime.entries.set(item.itemInstanceId, {
body, body,
item, item,
lockReadableTop: visual.lockReadableTop,
mesh: visual.mesh, mesh: visual.mesh,
topRotationY: visual.topRotationY,
}); });
}); });
}, [ready, run.items, run.snapshotVersion]); }, [ready, run.items, run.snapshotVersion]);
@@ -568,7 +1004,7 @@ export function Match3DPhysicsBoard({
entry.mesh.visible, entry.mesh.visible,
) )
.map((entry) => entry.mesh); .map((entry) => entry.mesh);
const hit = runtime.raycaster.intersectObjects(meshes, false)[0]; const hit = runtime.raycaster.intersectObjects(meshes, true)[0];
const itemInstanceId = const itemInstanceId =
typeof hit?.object.userData.itemInstanceId === 'string' typeof hit?.object.userData.itemInstanceId === 'string'
? hit.object.userData.itemInstanceId ? hit.object.userData.itemInstanceId
@@ -587,7 +1023,7 @@ export function Match3DPhysicsBoard({
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="absolute inset-0 z-10 overflow-hidden rounded-full" className="absolute inset-0 z-10 overflow-visible"
data-testid="match3d-physics-board" data-testid="match3d-physics-board"
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
> >

View File

@@ -2,7 +2,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import type { import type {
Match3DClickItemRequest, Match3DClickItemRequest,
@@ -12,16 +12,42 @@ import {
confirmLocalMatch3DClick, confirmLocalMatch3DClick,
startLocalMatch3DRun, startLocalMatch3DRun,
} from '../../services/match3d-runtime'; } from '../../services/match3d-runtime';
import {
MATCH3D_EXTRUDED_READABLE_SHAPES,
createMatch3DThreeGeometry,
} from './Match3DPhysicsBoard';
import { resolveGeometryAsset } from './match3dVisualAssets';
import { Match3DRuntimeShell } from './Match3DRuntimeShell'; import { Match3DRuntimeShell } from './Match3DRuntimeShell';
vi.mock('./Match3DPhysicsBoard', () => ({ vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => { const actual =
useEffect(() => { await importOriginal<typeof import('./Match3DPhysicsBoard')>();
onFallback(); return {
}, [onFallback]); ...actual,
return <div data-testid="match3d-physics-board-fallback" />; Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => {
}, useEffect(() => {
})); const shouldKeep3D =
(
globalThis as typeof globalThis & {
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
}
).__MATCH3D_KEEP_3D_TEST_RENDER__ === true;
if (!shouldKeep3D) {
onFallback();
}
}, [onFallback]);
return <div data-testid="match3d-physics-board-fallback" />;
},
};
});
afterEach(() => {
delete (
globalThis as typeof globalThis & {
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
}
).__MATCH3D_KEEP_3D_TEST_RENDER__;
});
function renderRuntime(run: Match3DRunSnapshot) { function renderRuntime(run: Match3DRunSnapshot) {
let currentRun = run; let currentRun = run;
@@ -79,13 +105,204 @@ test('点击可见物品后先乐观入槽再等待确认', async () => {
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1)); await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
}); });
test('后端形状视觉键不会被统一兜底成红色苹字', () => { test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋盘上下文', () => {
(
globalThis as typeof globalThis & {
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
}
).__MATCH3D_KEEP_3D_TEST_RENDER__ = true;
const run = startLocalMatch3DRun(1);
const selectedItem = run.items[0]!;
const nextRun: Match3DRunSnapshot = {
...run,
items: run.items.map((item, index) =>
index === 0
? {
...item,
state: 'InTray' as const,
clickable: false,
traySlotIndex: 0,
}
: item,
),
traySlots: run.traySlots.map((slot) =>
slot.slotIndex === 0
? {
slotIndex: 0,
itemInstanceId: selectedItem.itemInstanceId,
itemTypeId: selectedItem.itemTypeId,
visualKey: selectedItem.visualKey,
}
: slot,
),
};
renderRuntime(nextRun);
expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy();
});
test('本地试玩按消除次数生成类型并在 25 类封顶', () => {
const smallRun = startLocalMatch3DRun(12);
const largeRun = startLocalMatch3DRun(100);
const countTypes = (run: Match3DRunSnapshot) =>
new Set(run.items.map((item) => item.itemTypeId)).size;
expect(countTypes(smallRun)).toBe(12);
expect(countTypes(largeRun)).toBe(25);
expect(largeRun.items).toHaveLength(300);
});
test('25 次以内生成不重复积木视觉签名', () => {
const run = startLocalMatch3DRun(25);
const firstItemByType = new Map(
run.items.map((item) => [item.itemTypeId, item]),
);
const visualKeys = new Set(
[...firstItemByType.values()].map((item) => item.visualKey),
);
const signatures = new Set(
[...firstItemByType.values()].map(
(item) => {
const asset = resolveGeometryAsset(item.visualKey);
return `${asset.shape}-${asset.fill}-${asset.studsX}x${asset.studsY}-${asset.heightScale}`;
},
),
);
expect(firstItemByType.size).toBe(25);
expect(visualKeys.size).toBe(25);
expect(signatures.size).toBe(25);
});
test('积木池覆盖参考图里的特殊件', () => {
const shapes = new Set(
startLocalMatch3DRun(25).items.map((item) =>
resolveGeometryAsset(item.visualKey).shape,
),
);
expect(shapes).toContain('brick');
expect(shapes).toContain('tile');
expect(shapes).toContain('slope');
expect(shapes).toContain('cylinder');
expect(shapes).toContain('ring');
expect(shapes).toContain('arch');
expect(shapes).toContain('cone');
});
test('3D 特殊积木件使用可辨认挤出轮廓而不是基础代理体', async () => {
const three = await import('three');
for (const shape of MATCH3D_EXTRUDED_READABLE_SHAPES) {
const geometry = createMatch3DThreeGeometry(three, shape, 1);
expect(geometry.type).toBe('ExtrudeGeometry');
}
});
test('15 次消除时每种视觉模型只对应一次消除目标', () => {
const run = startLocalMatch3DRun(15);
const countByVisualKey = new Map<string, number>();
const typeByVisualKey = new Map<string, Set<string>>();
for (const item of run.items) {
countByVisualKey.set(
item.visualKey,
(countByVisualKey.get(item.visualKey) ?? 0) + 1,
);
typeByVisualKey.set(item.visualKey, typeByVisualKey.get(item.visualKey) ?? new Set());
typeByVisualKey.get(item.visualKey)!.add(item.itemTypeId);
}
expect(countByVisualKey.size).toBe(15);
expect([...countByVisualKey.values()]).toEqual(Array(15).fill(3));
expect(
[...typeByVisualKey.values()].every((itemTypeIds) => itemTypeIds.size === 1),
).toBe(true);
});
test('25 次以内的随机抽取不会刷新重复物品', () => {
for (const clearCount of [1, 12, 15, 24, 25]) {
const run = startLocalMatch3DRun(clearCount);
const visualKeys = new Set(run.items.map((item) => item.visualKey));
expect(visualKeys.size).toBe(clearCount);
}
});
test('25 类型局面按五档体积比例生成尺寸', () => {
const run = startLocalMatch3DRun(25);
const radiusByVisualKey = new Map<string, number>();
for (const item of run.items) {
radiusByVisualKey.set(item.visualKey, item.radius);
}
const baseRadius = [...radiusByVisualKey.values()].find(
(radius) => Math.abs(radius / 0.072 - 1) < 0.01,
);
expect(baseRadius).toBeTruthy();
const tierCounts = new Map<string, number>();
for (const radius of radiusByVisualKey.values()) {
const relativeVolume = Math.pow(radius / baseRadius!, 3);
const tier =
relativeVolume >= 1.6
? 'XL'
: relativeVolume >= 1.25
? 'L'
: relativeVolume >= 0.65 && relativeVolume <= 0.85
? 'XS'
: relativeVolume <= 0.5
? 'S'
: 'M';
tierCounts.set(tier, (tierCounts.get(tier) ?? 0) + 1);
}
expect(tierCounts.get('XL')).toBe(5);
expect(tierCounts.get('L')).toBe(8);
expect(tierCounts.get('M')).toBe(7);
expect(tierCounts.get('XS')).toBe(4);
expect(tierCounts.get('S')).toBe(1);
});
test('同一视觉模型在复用时保持唯一尺寸', () => {
const run = startLocalMatch3DRun(30);
const radiiByVisualKey = new Map<string, Set<number>>();
for (const item of run.items) {
const radii = radiiByVisualKey.get(item.visualKey) ?? new Set<number>();
radii.add(Math.round(item.radius * 10_000));
radiiByVisualKey.set(item.visualKey, radii);
}
expect(radiiByVisualKey.size).toBe(25);
expect([...radiiByVisualKey.values()].every((radii) => radii.size === 1)).toBe(true);
});
test('积木 3D 资源可以为本局类型创建几何体', async () => {
const three = await import('three');
const run = startLocalMatch3DRun(15);
const firstItemByType = new Map(
run.items.map((item) => [item.itemTypeId, item]),
);
expect(firstItemByType.size).toBe(15);
for (const item of firstItemByType.values()) {
const shape = resolveGeometryAsset(item.visualKey).shape;
const geometry = createMatch3DThreeGeometry(three, shape, 1);
expect(geometry).toBeTruthy();
}
});
test('积木视觉键不会被统一兜底成红色苹字', () => {
const run = startLocalMatch3DRun(2); const run = startLocalMatch3DRun(2);
run.items = run.items.slice(0, 2).map((item, index) => ({ run.items = run.items.slice(0, 2).map((item, index) => ({
...item, ...item,
itemInstanceId: `shape-${index}`, itemInstanceId: `block-${index}`,
itemTypeId: `shape-type-${index}`, itemTypeId: `block-type-${index}`,
visualKey: index === 0 ? 'red_circle' : 'yellow_triangle', visualKey: index === 0 ? 'block-red-2x4' : 'block-blue-1x2',
x: 0.42 + index * 0.16, x: 0.42 + index * 0.16,
y: 0.5, y: 0.5,
layer: index, layer: index,
@@ -93,23 +310,23 @@ test('后端形状视觉键不会被统一兜底成红色苹字', () => {
})); }));
renderRuntime(run); renderRuntime(run);
expect(screen.getByTestId('match3d-visual-red_circle')).toBeTruthy(); expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy();
expect(screen.getByTestId('match3d-visual-yellow_triangle')).toBeTruthy(); expect(screen.getByTestId('match3d-visual-block-blue-1x2')).toBeTruthy();
expect(screen.queryAllByText('苹')).toHaveLength(0); expect(screen.queryAllByText('苹')).toHaveLength(0);
}); });
test('水果题材视觉键渲染为无文字纯色几何体', () => { test('积木视觉键渲染为无文字纯色图标', () => {
const run = startLocalMatch3DRun(3); const run = startLocalMatch3DRun(3);
run.items = run.items.slice(0, 3).map((item, index) => ({ run.items = run.items.slice(0, 3).map((item, index) => ({
...item, ...item,
itemInstanceId: `fruit-${index}`, itemInstanceId: `block-icon-${index}`,
itemTypeId: `fruit-type-${index}`, itemTypeId: `block-icon-type-${index}`,
visualKey: visualKey:
index === 0 index === 0
? 'watermelon-green' ? 'block-red-2x4'
: index === 1 : index === 1
? 'apple-red' ? 'block-clear-ring'
: 'grape-purple', : 'block-mint-arch',
x: 0.35 + index * 0.15, x: 0.35 + index * 0.15,
y: 0.5, y: 0.5,
radius: index === 0 ? 0.12 : index === 1 ? 0.09 : 0.07, radius: index === 0 ? 0.12 : index === 1 ? 0.09 : 0.07,
@@ -118,31 +335,31 @@ test('水果题材视觉键也渲染为无文字纯色几何体', () => {
})); }));
renderRuntime(run); renderRuntime(run);
expect(screen.getByTestId('match3d-visual-watermelon-green')).toBeTruthy(); expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy();
expect( expect(
screen.getByTestId('match3d-visual-apple-red').getAttribute('data-shape'), screen.getByTestId('match3d-visual-block-clear-ring').getAttribute('data-shape'),
).toBe('heart'); ).toBe('ring');
expect( expect(
screen screen
.getByTestId('match3d-visual-grape-purple') .getByTestId('match3d-visual-block-mint-arch')
.getAttribute('data-shape'), .getAttribute('data-shape'),
).toBe('star'); ).toBe('arch');
expect(screen.queryByText('苹果')).toBeNull(); expect(screen.queryByText('苹果')).toBeNull();
expect(screen.queryByText('苹')).toBeNull(); expect(screen.queryByText('苹')).toBeNull();
}); });
test('运行态支持梯形和平行四边形等差异化几何造型', () => { test('运行态支持长条、斜坡和圆柱等差异化积木造型', () => {
const run = startLocalMatch3DRun(3); const run = startLocalMatch3DRun(3);
run.items = run.items.slice(0, 3).map((item, index) => ({ run.items = run.items.slice(0, 3).map((item, index) => ({
...item, ...item,
itemInstanceId: `geometry-${index}`, itemInstanceId: `block-geometry-${index}`,
itemTypeId: `geometry-type-${index}`, itemTypeId: `block-geometry-type-${index}`,
visualKey: visualKey:
index === 0 index === 0
? 'peach-pink' ? 'block-black-1x8'
: index === 1 : index === 1
? 'banana-yellow' ? 'block-purple-slope-1x2'
: 'orange_hexagon', : 'block-green-cylinder',
x: 0.35 + index * 0.15, x: 0.35 + index * 0.15,
y: 0.5, y: 0.5,
layer: index, layer: index,
@@ -151,18 +368,18 @@ test('运行态支持梯形和平行四边形等差异化几何造型', () => {
renderRuntime(run); renderRuntime(run);
expect( expect(
screen.getByTestId('match3d-visual-peach-pink').getAttribute('data-shape'), screen.getByTestId('match3d-visual-block-black-1x8').getAttribute('data-shape'),
).toBe('trapezoid'); ).toBe('brick');
expect( expect(
screen screen
.getByTestId('match3d-visual-banana-yellow') .getByTestId('match3d-visual-block-purple-slope-1x2')
.getAttribute('data-shape'), .getAttribute('data-shape'),
).toBe('parallelogram'); ).toBe('slope');
expect( expect(
screen screen
.getByTestId('match3d-visual-orange_hexagon') .getByTestId('match3d-visual-block-green-cylinder')
.getAttribute('data-shape'), .getAttribute('data-shape'),
).toBe('hexagon'); ).toBe('cylinder');
}); });
test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', () => { test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', () => {
@@ -172,7 +389,7 @@ test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', ()
{ {
...item, ...item,
itemInstanceId: 'legacy-outside', itemInstanceId: 'legacy-outside',
visualKey: 'apple-red', visualKey: 'block-red-2x4',
x: -0.4, x: -0.4,
y: 0.5, y: 0.5,
radius: 0.1, radius: 0.1,

View File

@@ -19,7 +19,10 @@ import {
Match3DVisualIcon, Match3DVisualIcon,
resolveVisualSeed, resolveVisualSeed,
} from './match3dVisualAssets'; } from './match3dVisualAssets';
import { Match3DPhysicsBoard } from './Match3DPhysicsBoard'; import {
Match3DPhysicsBoard,
Match3DTrayPreviewBoard,
} from './Match3DPhysicsBoard';
import { import {
isItemState, isItemState,
isRunState, isRunState,
@@ -178,19 +181,28 @@ function Match3DToken({
); );
} }
function Match3DTrayToken({ slot }: { slot: Match3DTraySlot }) { function Match3DTrayToken({
slot,
use3DPreview,
}: {
slot: Match3DTraySlot;
use3DPreview: boolean;
}) {
if (!slot.visualKey) { if (!slot.visualKey) {
return ( return (
<span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" /> <span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />
); );
} }
const visualSeed = resolveVisualSeed(slot.visualKey); const visualSeed = resolveVisualSeed(slot.visualKey);
const fallback = <Match3DVisualIcon visualKey={slot.visualKey} />;
return ( return (
<span <span
className="flex h-full w-full items-center justify-center p-1" className="flex h-full w-full items-center justify-center p-1"
aria-label={visualSeed.label} aria-label={visualSeed.label}
> >
<Match3DVisualIcon visualKey={slot.visualKey} /> <span className={use3DPreview ? 'opacity-0' : 'opacity-100'}>
{fallback}
</span>
</span> </span>
); );
} }
@@ -321,6 +333,18 @@ export function Match3DRuntimeShell({
}, [run]); }, [run]);
const shouldUse3DRender = !force2DRender; const shouldUse3DRender = !force2DRender;
const trayPreviewItems = useMemo(() => {
if (!run) {
return [];
}
return run.traySlots.map((slot) =>
slot.itemInstanceId
? (run.items.find(
(item) => item.itemInstanceId === slot.itemInstanceId,
) ?? null)
: null,
);
}, [run]);
const handleItemClick = async (item: Match3DItemSnapshot) => { const handleItemClick = async (item: Match3DItemSnapshot) => {
if (!run || !isRunState(run.status, 'running') || pendingClick) { if (!run || !isRunState(run.status, 'running') || pendingClick) {
@@ -436,7 +460,9 @@ export function Match3DRuntimeShell({
<section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center"> <section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
<div <div
ref={stageRef} ref={stageRef}
className="relative aspect-square max-w-full overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]" className={`relative aspect-square max-w-full rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)] ${
shouldUse3DRender ? 'overflow-visible' : 'overflow-hidden'
}`}
style={{ style={{
width: 'min(92vw, 58dvh, 100%)', width: 'min(92vw, 58dvh, 100%)',
}} }}
@@ -474,16 +500,27 @@ export function Match3DRuntimeShell({
</section> </section>
<section className="mt-3 w-full min-w-0 overflow-hidden rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur"> <section className="mt-3 w-full min-w-0 overflow-hidden rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
<div className="grid grid-cols-7 gap-1.5" data-testid="match3d-tray"> <div
{run.traySlots.map((slot) => ( className="relative grid grid-cols-7 gap-1.5"
<div data-testid="match3d-tray"
key={slot.slotIndex} >
className="aspect-square min-w-0 rounded-xl bg-white/10 p-1" {shouldUse3DRender ? (
data-testid="match3d-tray-slot" <Match3DTrayPreviewBoard slotItems={trayPreviewItems} />
> ) : null}
<Match3DTrayToken slot={slot} /> {run.traySlots.map((slot) => {
</div> return (
))} <div
key={slot.slotIndex}
className="relative z-0 aspect-square min-w-0 rounded-xl bg-white/10 p-1"
data-testid="match3d-tray-slot"
>
<Match3DTrayToken
slot={slot}
use3DPreview={shouldUse3DRender}
/>
</div>
);
})}
</div> </div>
</section> </section>
</div> </div>

View File

@@ -2,139 +2,63 @@ import { MATCH3D_VISUAL_SEEDS } from '../../services/match3d-runtime';
type Match3DVisualSeed = (typeof MATCH3D_VISUAL_SEEDS)[number]; type Match3DVisualSeed = (typeof MATCH3D_VISUAL_SEEDS)[number];
export type Match3DGeometryShape = export type Match3DBlockShape =
| 'circle' | 'brick'
| 'triangle' | 'tile'
| 'diamond' | 'slope'
| 'square' | 'cylinder'
| 'star' | 'ring'
| 'hexagon' | 'arch'
| 'capsule' | 'cone';
| 'heart'
| 'trapezoid' export type Match3DGeometryShape = Match3DBlockShape;
| 'parallelogram';
export type Match3DGeometryAsset = { export type Match3DGeometryAsset = {
shape: Match3DGeometryShape; shape: Match3DBlockShape;
fill: string; fill: string;
stroke: string; stroke: string;
studsX: number;
studsY: number;
heightScale: number;
transparent?: boolean;
}; };
const MATCH3D_GEOMETRY_ASSETS: Record<string, Match3DGeometryAsset> = { const MATCH3D_GEOMETRY_ASSETS: Record<string, Match3DGeometryAsset> = {
'watermelon-green': { 'block-red-2x4': blockAsset('brick', '#e31818', '#8f1111', 4, 2, 0.72),
shape: 'circle', 'block-blue-1x2': blockAsset('brick', '#1478d4', '#0b4f91', 2, 1, 0.82),
fill: '#16a34a', 'block-yellow-2x2': blockAsset('brick', '#f7c51d', '#a66f00', 2, 2, 0.76),
stroke: '#14532d', 'block-green-1x4': blockAsset('brick', '#079447', '#055c2f', 4, 1, 0.72),
}, 'block-orange-1x6': blockAsset('brick', '#ff7a12', '#b84708', 6, 1, 0.64),
'apple-red': { 'block-white-1x1': blockAsset('brick', '#f3f2ec', '#b7b8b2', 1, 1, 0.86),
shape: 'heart', 'block-black-1x8': blockAsset('brick', '#101214', '#030405', 8, 1, 0.54),
fill: '#ef4444', 'block-tan-2x3': blockAsset('brick', '#d8bd72', '#9b7a35', 3, 2, 0.68),
stroke: '#991b1b', 'block-lime-1x2': blockAsset('brick', '#a5df18', '#6d990b', 2, 1, 0.58),
}, 'block-darkred-2x2': blockAsset('brick', '#b51217', '#76090d', 2, 2, 0.7),
'banana-yellow': { 'block-blue-1x4': blockAsset('brick', '#1688df', '#0b5c9e', 4, 1, 0.58),
shape: 'parallelogram', 'block-pink-2x4': blockAsset('brick', '#f66bb5', '#ba2e7e', 4, 2, 0.56),
fill: '#facc15', 'block-gray-1x6': blockAsset('brick', '#4c5456', '#232829', 6, 1, 0.5),
stroke: '#a16207', 'block-lavender-tile-2x2': blockAsset('tile', '#c99fe6', '#8b63ad', 2, 2, 0.28),
}, 'block-teal-tile-1x3': blockAsset('tile', '#11adb0', '#087377', 3, 1, 0.26),
'grape-purple': { 'block-mint-tile-1x4': blockAsset('tile', '#a7c6ac', '#6e9275', 4, 1, 0.24),
shape: 'star', 'block-magenta-tile-2x2': blockAsset('tile', '#cf0f68', '#8e0644', 2, 2, 0.28),
fill: '#8b5cf6', 'block-orange-tile-2x2-stud': blockAsset('tile', '#ff970f', '#b65b05', 2, 2, 0.3),
stroke: '#5b21b6', 'block-purple-slope-1x2': blockAsset('slope', '#5e42b6', '#342070', 2, 1, 0.82),
}, 'block-brown-slope-1x2': blockAsset('slope', '#8b421f', '#552414', 2, 1, 0.94),
'melon-green': { 'block-sky-slope-2x2': blockAsset('slope', '#4db3f2', '#1f78b7', 2, 2, 0.9),
shape: 'hexagon', 'block-green-cylinder': blockAsset('cylinder', '#159554', '#076236', 1, 1, 1.08),
fill: '#84cc16', 'block-clear-ring': {
stroke: '#3f6212', ...blockAsset('ring', '#d9e1df', '#aebbbb', 2, 2, 0.38),
}, transparent: true,
'berry-blue': {
shape: 'diamond',
fill: '#2563eb',
stroke: '#1e3a8a',
},
'peach-pink': {
shape: 'trapezoid',
fill: '#fb7185',
stroke: '#be123c',
},
'plum-indigo': {
shape: 'capsule',
fill: '#4f46e5',
stroke: '#312e81',
},
'lime-lime': {
shape: 'square',
fill: '#65a30d',
stroke: '#365314',
},
'orange-orange': {
shape: 'triangle',
fill: '#f97316',
stroke: '#9a3412',
},
'pear-cyan': {
shape: 'parallelogram',
fill: '#06b6d4',
stroke: '#155e75',
},
red_circle: {
shape: 'circle',
fill: '#ef4444',
stroke: '#991b1b',
},
yellow_triangle: {
shape: 'triangle',
fill: '#facc15',
stroke: '#a16207',
},
purple_diamond: {
shape: 'diamond',
fill: '#7c3aed',
stroke: '#4c1d95',
},
green_square: {
shape: 'square',
fill: '#16a34a',
stroke: '#14532d',
},
blue_star: {
shape: 'star',
fill: '#0ea5e9',
stroke: '#075985',
},
orange_hexagon: {
shape: 'hexagon',
fill: '#f97316',
stroke: '#9a3412',
},
cyan_capsule: {
shape: 'capsule',
fill: '#06b6d4',
stroke: '#155e75',
},
pink_heart: {
shape: 'heart',
fill: '#ec4899',
stroke: '#9d174d',
},
lime_leaf: {
shape: 'trapezoid',
fill: '#84cc16',
stroke: '#3f6212',
},
white_moon: {
shape: 'parallelogram',
fill: '#e2e8f0',
stroke: '#64748b',
}, },
'block-mint-arch': blockAsset('arch', '#c4ded2', '#83a996', 4, 1, 1.0),
'block-gold-cone': blockAsset('cone', '#d39a10', '#8c6105', 1, 1, 1.18),
}; };
const MATCH3D_UNKNOWN_GEOMETRY_ASSETS: Match3DGeometryAsset[] = [ const MATCH3D_UNKNOWN_GEOMETRY_ASSETS: Match3DGeometryAsset[] = [
{ shape: 'circle', fill: '#f43f5e', stroke: '#9f1239' }, blockAsset('brick', '#e11d48', '#9f1239', 2, 2, 0.68),
{ shape: 'triangle', fill: '#f59e0b', stroke: '#92400e' }, blockAsset('tile', '#f59e0b', '#92400e', 3, 1, 0.28),
{ shape: 'diamond', fill: '#8b5cf6', stroke: '#5b21b6' }, blockAsset('slope', '#8b5cf6', '#5b21b6', 2, 1, 0.86),
{ shape: 'star', fill: '#10b981', stroke: '#065f46' }, blockAsset('cylinder', '#10b981', '#065f46', 1, 1, 1.0),
{ shape: 'trapezoid', fill: '#0ea5e9', stroke: '#075985' },
{ shape: 'parallelogram', fill: '#14b8a6', stroke: '#115e59' },
]; ];
const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [ const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [
@@ -162,14 +86,26 @@ const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [
colorClassName: 'from-emerald-300 to-green-600', colorClassName: 'from-emerald-300 to-green-600',
label: '四', label: '四',
}, },
{
itemTypeId: 'unknown-sky',
visualKey: 'unknown-sky',
colorClassName: 'from-sky-300 to-blue-600',
label: '五',
},
]; ];
function blockAsset(
shape: Match3DBlockShape,
fill: string,
stroke: string,
studsX: number,
studsY: number,
heightScale: number,
): Match3DGeometryAsset {
return {
shape,
fill,
stroke,
studsX,
studsY,
heightScale,
};
}
export function hashVisualKey(visualKey: string) { export function hashVisualKey(visualKey: string) {
let hash = 0; let hash = 0;
for (const char of visualKey) { for (const char of visualKey) {
@@ -199,48 +135,80 @@ export function resolveGeometryAsset(visualKey: string): Match3DGeometryAsset {
); );
} }
function renderGeometryShape(asset: Match3DGeometryAsset) { function renderBlockIcon(asset: Match3DGeometryAsset) {
const shapeProps = { const shapeProps = {
fill: asset.fill, fill: asset.fill,
stroke: asset.stroke, stroke: asset.stroke,
strokeWidth: 6, strokeWidth: 5,
strokeLinejoin: 'round' as const, strokeLinejoin: 'round' as const,
opacity: asset.transparent ? 0.72 : 1,
}; };
switch (asset.shape) { if (asset.shape === 'cylinder') {
case 'circle': return (
return <circle cx="50" cy="50" r="36" {...shapeProps} />; <>
case 'triangle': <rect x="34" y="22" width="32" height="56" rx="12" {...shapeProps} />
return <path d="M50 12 L89 84 H11Z" {...shapeProps} />; <ellipse cx="50" cy="24" rx="16" ry="8" fill={asset.fill} stroke={asset.stroke} strokeWidth={5} />
case 'diamond': </>
return <path d="M50 9 L91 50 L50 91 L9 50Z" {...shapeProps} />; );
case 'square':
return <rect x="16" y="16" width="68" height="68" rx="8" {...shapeProps} />;
case 'star':
return (
<path
d="M50 8 L61 36 L91 38 L68 58 L76 88 L50 72 L24 88 L32 58 L9 38 L39 36Z"
{...shapeProps}
/>
);
case 'hexagon':
return <path d="M28 12 H72 L94 50 L72 88 H28 L6 50Z" {...shapeProps} />;
case 'capsule':
return <rect x="10" y="28" width="80" height="44" rx="22" {...shapeProps} />;
case 'heart':
return (
<path
d="M50 86 C25 66 13 52 17 34 C20 18 40 16 50 31 C60 16 80 18 83 34 C87 52 75 66 50 86Z"
{...shapeProps}
/>
);
case 'trapezoid':
return <path d="M27 18 H73 L90 82 H10Z" {...shapeProps} />;
case 'parallelogram':
return <path d="M34 16 H88 L66 84 H12Z" {...shapeProps} />;
default:
return <circle cx="50" cy="50" r="36" {...shapeProps} />;
} }
if (asset.shape === 'ring') {
return (
<>
<ellipse cx="50" cy="50" rx="34" ry="24" {...shapeProps} />
<ellipse cx="50" cy="50" rx="17" ry="11" fill="rgba(255,255,255,0.88)" stroke={asset.stroke} strokeWidth={5} />
</>
);
}
if (asset.shape === 'arch') {
return (
<path
d="M14 78 V28 H86 V78 H66 V46 C66 34 58 27 50 27 C42 27 34 34 34 46 V78Z"
{...shapeProps}
/>
);
}
if (asset.shape === 'cone') {
return (
<path d="M50 12 C66 28 78 62 78 82 H22 C22 62 34 28 50 12Z" {...shapeProps} />
);
}
if (asset.shape === 'slope') {
return <path d="M16 76 L84 76 L84 30 L16 60Z" {...shapeProps} />;
}
const width = Math.min(76, 16 + asset.studsX * 14);
const height = Math.min(54, 18 + asset.studsY * 13);
const x = 50 - width / 2;
const y = 54 - height / 2;
const studRadius = asset.shape === 'tile' ? 0 : 5;
return (
<>
<rect x={x} y={y} width={width} height={height} rx="7" {...shapeProps} />
{Array.from({ length: asset.studsX * asset.studsY }, (_, index) => {
if (studRadius <= 0) {
return null;
}
const column = index % asset.studsX;
const row = Math.floor(index / asset.studsX);
return (
<circle
key={index}
cx={x + ((column + 0.5) * width) / asset.studsX}
cy={y + ((row + 0.5) * height) / asset.studsY}
r={studRadius}
fill={asset.fill}
stroke={asset.stroke}
strokeWidth={3}
/>
);
})}
</>
);
} }
export function Match3DVisualIcon({ export function Match3DVisualIcon({
@@ -261,7 +229,7 @@ export function Match3DVisualIcon({
data-testid={`match3d-visual-${visualKey}`} data-testid={`match3d-visual-${visualKey}`}
data-shape={asset.shape} data-shape={asset.shape}
> >
{renderGeometryShape(asset)} {renderBlockIcon(asset)}
</svg> </svg>
); );
} }

View File

@@ -9,7 +9,10 @@ import type {
AuthUser, AuthUser,
PublicUserSummary, PublicUserSummary,
} from '../../../packages/shared/src/contracts/auth'; } from '../../../packages/shared/src/contracts/auth';
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime'; import type {
ProfileReferralInviteCenterResponse,
ProfileTaskCenterResponse,
} from '../../../packages/shared/src/contracts/runtime';
import { AuthUiContext } from '../auth/AuthUiContext'; import { AuthUiContext } from '../auth/AuthUiContext';
import { import {
RpgEntryHomeView, RpgEntryHomeView,
@@ -19,7 +22,10 @@ import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
const { const {
mockBuildReferralCenter, mockBuildReferralCenter,
mockBuildTaskCenter,
mockClaimRpgProfileTaskReward,
mockGetRpgProfileReferralInviteCenter, mockGetRpgProfileReferralInviteCenter,
mockGetRpgProfileTasks,
mockGetRpgProfileWalletLedger, mockGetRpgProfileWalletLedger,
mockRedeemRpgProfileReferralInviteCode, mockRedeemRpgProfileReferralInviteCode,
} = vi.hoisted(() => { } = vi.hoisted(() => {
@@ -47,12 +53,73 @@ const {
updatedAt: '2026-05-01T08:00:00Z', updatedAt: '2026-05-01T08:00:00Z',
...overrides, ...overrides,
}); });
const buildTaskCenter = (
overrides: Partial<ProfileTaskCenterResponse> = {},
): ProfileTaskCenterResponse => ({
dayKey: 20260503,
walletBalance: 0,
tasks: [
{
taskId: 'daily_login',
title: '每日登录',
description: '',
eventKey: 'profile.login.daily',
cycle: 'daily',
threshold: 1,
progressCount: 1,
rewardPoints: 10,
status: 'claimable',
dayKey: 20260503,
claimedAt: null,
updatedAt: '2026-05-03T08:00:00Z',
},
],
updatedAt: '2026-05-03T08:00:00Z',
...overrides,
});
const buildClaimedTaskCenter = () =>
buildTaskCenter({
walletBalance: 10,
tasks: [
{
taskId: 'daily_login',
title: '每日登录',
description: '',
eventKey: 'profile.login.daily',
cycle: 'daily',
threshold: 1,
progressCount: 1,
rewardPoints: 10,
status: 'claimed',
dayKey: 20260503,
claimedAt: '2026-05-03T08:01:00Z',
updatedAt: '2026-05-03T08:01:00Z',
},
],
updatedAt: '2026-05-03T08:01:00Z',
});
return { return {
mockBuildReferralCenter: buildReferralCenter, mockBuildReferralCenter: buildReferralCenter,
mockBuildTaskCenter: buildTaskCenter,
mockGetRpgProfileReferralInviteCenter: vi.fn(async () => mockGetRpgProfileReferralInviteCenter: vi.fn(async () =>
buildReferralCenter(), buildReferralCenter(),
), ),
mockGetRpgProfileTasks: vi.fn(async () => buildTaskCenter()),
mockClaimRpgProfileTaskReward: vi.fn(async () => ({
taskId: 'daily_login',
dayKey: 20260503,
rewardPoints: 10,
walletBalance: 10,
ledgerEntry: {
id: 'ledger-daily-login',
amountDelta: 10,
balanceAfter: 10,
sourceType: 'daily_task_reward',
createdAt: '2026-05-03T08:01:00Z',
},
center: buildClaimedTaskCenter(),
})),
mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({ mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({
center: buildReferralCenter({ center: buildReferralCenter({
invitedUsers: [], invitedUsers: [],
@@ -131,7 +198,9 @@ mockUpdateAuthProfile.mockResolvedValue({
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
getRpgProfileReferralInviteCenter: mockGetRpgProfileReferralInviteCenter, getRpgProfileReferralInviteCenter: mockGetRpgProfileReferralInviteCenter,
getRpgProfileTasks: mockGetRpgProfileTasks,
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger, getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
claimRpgProfileTaskReward: mockClaimRpgProfileTaskReward,
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode, redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
getRpgProfileRechargeCenter: vi.fn(async () => ({ getRpgProfileRechargeCenter: vi.fn(async () => ({
walletBalance: 0, walletBalance: 0,
@@ -558,6 +627,40 @@ afterEach(() => {
mockGetRpgProfileReferralInviteCenter.mockResolvedValue( mockGetRpgProfileReferralInviteCenter.mockResolvedValue(
mockBuildReferralCenter(), mockBuildReferralCenter(),
); );
mockGetRpgProfileTasks.mockResolvedValue(mockBuildTaskCenter());
mockClaimRpgProfileTaskReward.mockResolvedValue({
taskId: 'daily_login',
dayKey: 20260503,
rewardPoints: 10,
walletBalance: 10,
ledgerEntry: {
id: 'ledger-daily-login',
amountDelta: 10,
balanceAfter: 10,
sourceType: 'daily_task_reward',
createdAt: '2026-05-03T08:01:00Z',
},
center: mockBuildTaskCenter({
walletBalance: 10,
tasks: [
{
taskId: 'daily_login',
title: '每日登录',
description: '',
eventKey: 'profile.login.daily',
cycle: 'daily',
threshold: 1,
progressCount: 1,
rewardPoints: 10,
status: 'claimed',
dayKey: 20260503,
claimedAt: '2026-05-03T08:01:00Z',
updatedAt: '2026-05-03T08:01:00Z',
},
],
updatedAt: '2026-05-03T08:01:00Z',
}),
});
mockUpdateAuthProfile.mockResolvedValue({ mockUpdateAuthProfile.mockResolvedValue({
id: 'user-1', id: 'user-1',
publicUserCode: '100001', publicUserCode: '100001',
@@ -605,6 +708,31 @@ test('opens wallet ledger modal from narrative coin card', async () => {
expect(screen.getByText('+30')).toBeTruthy(); expect(screen.getByText('+30')).toBeTruthy();
}); });
test('profile daily task shortcut opens task center and claims reward', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess);
await user.click(screen.getByRole('button', { name: //u }));
expect(await screen.findByText('每日登录')).toBeTruthy();
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
expect(screen.getByText('1/1')).toBeTruthy();
expect(screen.getByText('+10')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '领取' }));
await waitFor(() => {
expect(mockClaimRpgProfileTaskReward).toHaveBeenCalledWith('daily_login');
});
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
expect(await screen.findByText('已领取 10 光点')).toBeTruthy();
expect(
(screen.getByRole('button', { name: '已领取' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
test('profile total play time card always uses hours', () => { test('profile total play time card always uses hours', () => {
renderProfileView(vi.fn(), { renderProfileView(vi.fn(), {
totalPlayTimeMs: 90 * 60 * 1000, totalPlayTimeMs: 90 * 60 * 1000,

View File

@@ -48,6 +48,8 @@ import type {
ProfilePlayStatsResponse, ProfilePlayStatsResponse,
ProfileReferralInviteCenterResponse, ProfileReferralInviteCenterResponse,
ProfileSaveArchiveSummary, ProfileSaveArchiveSummary,
ProfileTaskCenterResponse,
ProfileTaskItem,
ProfileWalletLedgerResponse, ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse, RedeemProfileRewardCodeResponse,
} from '../../../packages/shared/src/contracts/runtime'; } from '../../../packages/shared/src/contracts/runtime';
@@ -61,7 +63,9 @@ import {
import { copyTextToClipboard } from '../../services/clipboard'; import { copyTextToClipboard } from '../../services/clipboard';
import { import {
getRpgProfileReferralInviteCenter, getRpgProfileReferralInviteCenter,
getRpgProfileTasks,
getRpgProfileWalletLedger, getRpgProfileWalletLedger,
claimRpgProfileTaskReward,
redeemRpgProfileReferralInviteCode, redeemRpgProfileReferralInviteCode,
redeemRpgProfileRewardCode, redeemRpgProfileRewardCode,
} from '../../services/rpg-entry/rpgProfileClient'; } from '../../services/rpg-entry/rpgProfileClient';
@@ -2004,6 +2008,7 @@ const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
asset_operation_consume: '资产操作消耗', asset_operation_consume: '资产操作消耗',
asset_operation_refund: '资产操作退回', asset_operation_refund: '资产操作退回',
redeem_code_reward: '兑换码奖励', redeem_code_reward: '兑换码奖励',
daily_task_reward: '每日任务奖励',
}; };
function formatWalletLedgerAmount(amountDelta: number) { function formatWalletLedgerAmount(amountDelta: number) {
@@ -2119,6 +2124,142 @@ function WalletLedgerModal({
); );
} }
const PROFILE_TASK_STATUS_LABELS: Record<ProfileTaskItem['status'], string> = {
incomplete: '未完成',
claimable: '可领取',
claimed: '已领取',
disabled: '已停用',
};
function ProfileTaskCenterModal({
center,
isLoading,
error,
success,
claimingTaskId,
fallbackBalance,
onClose,
onRetry,
onClaim,
}: {
center: ProfileTaskCenterResponse | null;
isLoading: boolean;
error: string | null;
success: string | null;
claimingTaskId: string | null;
fallbackBalance: number;
onClose: () => void;
onRetry: () => void;
onClaim: (taskId: string) => void;
}) {
const tasks = center?.tasks ?? [];
const walletBalance = center?.walletBalance ?? fallbackBalance;
return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div className="platform-recharge-modal w-full max-w-md overflow-hidden rounded-[1.4rem]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-base font-black"></div>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{walletBalance}
</div>
</div>
<button
type="button"
aria-label="关闭每日任务"
onClick={onClose}
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="space-y-3 px-5 py-5">
{error ? (
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
<div>{error}</div>
<button
type="button"
onClick={onRetry}
className="platform-primary-button mt-3 rounded-2xl px-4 py-2 text-xs font-black"
>
</button>
</div>
) : null}
{success ? (
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
{success}
</div>
) : null}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, index) => (
<div
key={index}
className="h-20 animate-pulse rounded-2xl bg-white/10"
/>
))}
</div>
) : tasks.length === 0 ? (
<div className="platform-subpanel rounded-2xl px-4 py-8 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
) : (
<div className="space-y-3">
{tasks.map((task) => {
const isClaimable = task.status === 'claimable';
const isClaiming = claimingTaskId === task.taskId;
const progressLabel = `${Math.min(task.progressCount, task.threshold)}/${task.threshold}`;
return (
<div
key={task.taskId}
className="platform-subpanel rounded-2xl px-4 py-4"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-base font-black text-[var(--platform-text-strong)]">
{task.title}
</div>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{progressLabel}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
+{task.rewardPoints}
</div>
<div className="mt-1 text-[11px] font-semibold text-[var(--platform-text-soft)]">
{PROFILE_TASK_STATUS_LABELS[task.status]}
</div>
</div>
</div>
<button
type="button"
disabled={!isClaimable || Boolean(claimingTaskId)}
onClick={() => onClaim(task.taskId)}
className="platform-primary-button mt-3 w-full rounded-2xl px-4 py-2.5 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
>
{isClaiming
? '领取中'
: task.status === 'claimed'
? '已领取'
: isClaimable
? '领取'
: '未完成'}
</button>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function RewardCodeRedeemModal({ function RewardCodeRedeemModal({
value, value,
isSubmitting, isSubmitting,
@@ -2528,6 +2669,14 @@ export function RpgEntryHomeView({
null, null,
); );
const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false); const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false);
const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false);
const [taskCenter, setTaskCenter] = useState<ProfileTaskCenterResponse | null>(
null,
);
const [taskCenterError, setTaskCenterError] = useState<string | null>(null);
const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false);
const [claimingTaskId, setClaimingTaskId] = useState<string | null>(null);
const [taskClaimSuccess, setTaskClaimSuccess] = useState<string | null>(null);
const [profilePopupPanel, setProfilePopupPanel] = const [profilePopupPanel, setProfilePopupPanel] =
useState<ProfilePopupPanel | null>(null); useState<ProfilePopupPanel | null>(null);
const [referralCenter, setReferralCenter] = const [referralCenter, setReferralCenter] =
@@ -2961,6 +3110,24 @@ export function RpgEntryHomeView({
setIsWalletLedgerOpen(true); setIsWalletLedgerOpen(true);
loadWalletLedger(); loadWalletLedger();
}; };
const loadTaskCenter = () => {
setTaskCenterError(null);
setIsLoadingTaskCenter(true);
void getRpgProfileTasks()
.then(setTaskCenter)
.catch((error: unknown) => {
setTaskCenter(null);
setTaskCenterError(
error instanceof Error ? error.message : '读取每日任务失败',
);
})
.finally(() => setIsLoadingTaskCenter(false));
};
const openTaskCenterPanel = () => {
setIsTaskCenterOpen(true);
setTaskClaimSuccess(null);
loadTaskCenter();
};
const loadReferralCenter = useCallback(() => { const loadReferralCenter = useCallback(() => {
setIsLoadingReferral(true); setIsLoadingReferral(true);
setIsReferralCenterInitialized(false); setIsReferralCenterInitialized(false);
@@ -3070,6 +3237,27 @@ export function RpgEntryHomeView({
}) })
.finally(() => setIsSubmittingRewardCode(false)); .finally(() => setIsSubmittingRewardCode(false));
}; };
const claimTaskReward = (taskId: string) => {
if (claimingTaskId) {
return;
}
setClaimingTaskId(taskId);
setTaskCenterError(null);
setTaskClaimSuccess(null);
void claimRpgProfileTaskReward(taskId)
.then((response) => {
setTaskCenter(response.center);
setTaskClaimSuccess(`已领取 ${response.rewardPoints} 光点`);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setTaskCenterError(
error instanceof Error ? error.message : '领取任务奖励失败',
);
})
.finally(() => setClaimingTaskId(null));
};
const clearWorkSearch = () => { const clearWorkSearch = () => {
setActiveWorkSearchKeyword(''); setActiveWorkSearchKeyword('');
setDesktopSearchKeyword(''); setDesktopSearchKeyword('');
@@ -3714,6 +3902,17 @@ export function RpgEntryHomeView({
aria-label="常用功能" aria-label="常用功能"
> >
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<ProfileShortcutButton
label="每日任务"
subLabel={
<>
<span>10</span>
<Coins className="h-3 w-3" />
</>
}
icon={Star}
onClick={openTaskCenterPanel}
/>
<ProfileShortcutButton <ProfileShortcutButton
label="邀请好友" label="邀请好友"
subLabel={ subLabel={
@@ -4197,6 +4396,19 @@ export function RpgEntryHomeView({
/> />
) : null} ) : null}
{rewardCodeModal} {rewardCodeModal}
{isTaskCenterOpen ? (
<ProfileTaskCenterModal
center={taskCenter}
isLoading={isLoadingTaskCenter}
error={taskCenterError}
success={taskClaimSuccess}
claimingTaskId={claimingTaskId}
fallbackBalance={remainingNarrativeCoins}
onClose={() => setIsTaskCenterOpen(false)}
onRetry={loadTaskCenter}
onClaim={claimTaskReward}
/>
) : null}
{isProfilePlayStatsOpen ? ( {isProfilePlayStatsOpen ? (
<ProfilePlayedWorksModal <ProfilePlayedWorksModal
stats={profilePlayStats} stats={profilePlayStats}
@@ -4303,6 +4515,19 @@ export function RpgEntryHomeView({
</div> </div>
</div> </div>
{rewardCodeModal} {rewardCodeModal}
{isTaskCenterOpen ? (
<ProfileTaskCenterModal
center={taskCenter}
isLoading={isLoadingTaskCenter}
error={taskCenterError}
success={taskClaimSuccess}
claimingTaskId={claimingTaskId}
fallbackBalance={remainingNarrativeCoins}
onClose={() => setIsTaskCenterOpen(false)}
onRetry={loadTaskCenter}
onClaim={claimTaskReward}
/>
) : null}
{profilePopupPanel ? ( {profilePopupPanel ? (
<ProfileReferralModal <ProfileReferralModal
panel={profilePopupPanel} panel={profilePopupPanel}

View File

@@ -21,4 +21,22 @@ describe('vite dev api proxy', () => {
}), }),
); );
}); });
it('forwards the admin route to the admin dev server', async () => {
const resolvedConfig =
typeof viteConfig === 'function'
? await viteConfig({ command: 'serve', mode: 'test' })
: viteConfig;
// 中文注释:本地完整栈需要能从主站 `/admin/` 进入后台,贴近生产同域部署形态。
expect(resolvedConfig.server?.proxy).toEqual(
expect.objectContaining({
'/admin/': expect.objectContaining({
target: expect.stringContaining(':3102'),
changeOrigin: true,
secure: false,
}),
}),
);
});
}); });

View File

@@ -8,156 +8,237 @@ import type {
const MATCH3D_TRAY_SLOT_COUNT = 7; const MATCH3D_TRAY_SLOT_COUNT = 7;
const MATCH3D_LOCAL_DURATION_MS = 600_000; const MATCH3D_LOCAL_DURATION_MS = 600_000;
const MATCH3D_MAX_ITEM_TYPE_COUNT = 25;
const MATCH3D_LOCAL_BASE_RADIUS = 0.072;
type Match3DSizeTier = 'XL' | 'L' | 'M' | 'XS' | 'S';
type Match3DVisualSeed = { type Match3DVisualSeed = {
itemTypeId: string; itemTypeId: string;
visualKey: string; visualKey: string;
colorClassName: string; colorClassName: string;
label: string; label: string;
sizeScale?: number;
}; };
type Match3DSelectedVisualSeed = Match3DVisualSeed & {
radiusScale: number;
relativeVolume: number;
sizeTier: Match3DSizeTier;
};
const MATCH3D_SIZE_TIER_RULES: Array<{
radiusScale: number;
ratio: number;
relativeVolume: number;
sizeTier: Match3DSizeTier;
}> = [
{ sizeTier: 'XL', ratio: 0.2, relativeVolume: 1.86, radiusScale: 1.23 },
{ sizeTier: 'L', ratio: 0.3, relativeVolume: 1.4, radiusScale: 1.12 },
{ sizeTier: 'M', ratio: 0.3, relativeVolume: 1, radiusScale: 1 },
{ sizeTier: 'XS', ratio: 0.15, relativeVolume: 0.73, radiusScale: 0.9 },
{ sizeTier: 'S', ratio: 0.05, relativeVolume: 0.44, radiusScale: 0.76 },
];
export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [ export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
// 中文注释:水果题材内置视觉键要和后端 module-match3d 保持一致,避免不同物品被兜底成同一图案 // 中文注释:默认 25 类使用参考图中的积木件,形状、尺寸和颜色都要能区分
{ {
itemTypeId: 'watermelon', itemTypeId: 'block-red-2x4',
visualKey: 'watermelon-green', visualKey: 'block-red-2x4',
colorClassName: 'from-emerald-500 to-green-800', colorClassName: 'from-rose-400 to-red-700',
label: '西瓜', label: '红色二乘四',
sizeScale: 1.24,
}, },
{ {
itemTypeId: 'apple', itemTypeId: 'block-blue-1x2',
visualKey: 'apple-red', visualKey: 'block-blue-1x2',
colorClassName: 'from-rose-400 to-red-600', colorClassName: 'from-blue-300 to-blue-700',
label: '苹果', label: '蓝色一乘二',
sizeScale: 1,
}, },
{ {
itemTypeId: 'banana', itemTypeId: 'block-yellow-2x2',
visualKey: 'banana-yellow', visualKey: 'block-yellow-2x2',
colorClassName: 'from-yellow-300 to-amber-500', colorClassName: 'from-yellow-300 to-yellow-600',
label: '香蕉', label: '黄色二乘二',
sizeScale: 1.04,
}, },
{ {
itemTypeId: 'grape', itemTypeId: 'block-green-1x4',
visualKey: 'grape-purple', visualKey: 'block-green-1x4',
colorClassName: 'from-violet-400 to-purple-700', colorClassName: 'from-emerald-300 to-green-700',
label: '葡萄', label: '绿色一乘四',
sizeScale: 0.78,
}, },
{ {
itemTypeId: 'melon', itemTypeId: 'block-orange-1x6',
visualKey: 'melon-green', visualKey: 'block-orange-1x6',
colorClassName: 'from-emerald-300 to-green-600', colorClassName: 'from-orange-300 to-orange-700',
label: '甜瓜', label: '橙色一乘六',
sizeScale: 1.12,
}, },
{ {
itemTypeId: 'berry', itemTypeId: 'block-white-1x1',
visualKey: 'berry-blue', visualKey: 'block-white-1x1',
colorClassName: 'from-sky-300 to-blue-600', colorClassName: 'from-slate-50 to-slate-300',
label: '蓝莓', label: '白色一乘一',
sizeScale: 0.78,
}, },
{ {
itemTypeId: 'peach', itemTypeId: 'block-black-1x8',
visualKey: 'peach-pink', visualKey: 'block-black-1x8',
colorClassName: 'from-pink-300 to-orange-400', colorClassName: 'from-zinc-700 to-black',
label: '桃子', label: '黑色一乘八',
sizeScale: 1,
}, },
{ {
itemTypeId: 'plum', itemTypeId: 'block-tan-2x3',
visualKey: 'plum-indigo', visualKey: 'block-tan-2x3',
colorClassName: 'from-indigo-300 to-indigo-700', colorClassName: 'from-amber-100 to-yellow-600',
label: '李子', label: '米色二乘三',
sizeScale: 0.86,
}, },
{ {
itemTypeId: 'lime', itemTypeId: 'block-lime-1x2',
visualKey: 'lime-lime', visualKey: 'block-lime-1x2',
colorClassName: 'from-lime-300 to-lime-600', colorClassName: 'from-lime-300 to-lime-700',
label: '青柠', label: '青柠一乘二',
sizeScale: 0.86,
}, },
{ {
itemTypeId: 'orange', itemTypeId: 'block-darkred-2x2',
visualKey: 'orange-orange', visualKey: 'block-darkred-2x2',
colorClassName: 'from-orange-300 to-orange-600', colorClassName: 'from-red-700 to-red-950',
label: '橙子', label: '深红二乘二',
sizeScale: 1,
}, },
{ {
itemTypeId: 'pear', itemTypeId: 'block-blue-1x4',
visualKey: 'pear-cyan', visualKey: 'block-blue-1x4',
colorClassName: 'from-cyan-300 to-teal-600', colorClassName: 'from-sky-300 to-blue-700',
label: '', label: '蓝色一乘四',
sizeScale: 1,
}, },
{ {
itemTypeId: 'red-circle', itemTypeId: 'block-pink-2x4',
visualKey: 'red_circle', visualKey: 'block-pink-2x4',
colorClassName: 'from-rose-400 to-red-600', colorClassName: 'from-pink-300 to-pink-600',
label: '', label: '粉色二乘四',
}, },
{ {
itemTypeId: 'yellow-triangle', itemTypeId: 'block-gray-1x6',
visualKey: 'yellow_triangle', visualKey: 'block-gray-1x6',
colorClassName: 'from-yellow-300 to-amber-500', colorClassName: 'from-zinc-400 to-zinc-700',
label: '', label: '灰色一乘六',
}, },
{ {
itemTypeId: 'purple-diamond', itemTypeId: 'block-lavender-tile-2x2',
visualKey: 'purple_diamond', visualKey: 'block-lavender-tile-2x2',
colorClassName: 'from-violet-400 to-purple-700', colorClassName: 'from-purple-200 to-purple-500',
label: '', label: '薰衣草光板',
}, },
{ {
itemTypeId: 'green-square', itemTypeId: 'block-teal-tile-1x3',
visualKey: 'green_square', visualKey: 'block-teal-tile-1x3',
colorClassName: 'from-emerald-300 to-green-600', colorClassName: 'from-teal-300 to-teal-700',
label: '', label: '青色长光板',
}, },
{ {
itemTypeId: 'blue-star', itemTypeId: 'block-mint-tile-1x4',
visualKey: 'blue_star', visualKey: 'block-mint-tile-1x4',
colorClassName: 'from-sky-300 to-blue-600', colorClassName: 'from-emerald-100 to-emerald-400',
label: '', label: '薄荷长光板',
}, },
{ {
itemTypeId: 'orange-hexagon', itemTypeId: 'block-magenta-tile-2x2',
visualKey: 'orange_hexagon', visualKey: 'block-magenta-tile-2x2',
colorClassName: 'from-orange-300 to-orange-600', colorClassName: 'from-fuchsia-500 to-pink-800',
label: '', label: '洋红光板',
}, },
{ {
itemTypeId: 'cyan-capsule', itemTypeId: 'block-orange-tile-2x2-stud',
visualKey: 'cyan_capsule', visualKey: 'block-orange-tile-2x2-stud',
colorClassName: 'from-cyan-300 to-teal-600', colorClassName: 'from-orange-300 to-amber-700',
label: '', label: '橙色单钉板',
}, },
{ {
itemTypeId: 'pink-heart', itemTypeId: 'block-purple-slope-1x2',
visualKey: 'pink_heart', visualKey: 'block-purple-slope-1x2',
colorClassName: 'from-pink-300 to-rose-500', colorClassName: 'from-violet-400 to-violet-900',
label: '', label: '紫色斜坡',
}, },
{ {
itemTypeId: 'lime-leaf', itemTypeId: 'block-brown-slope-1x2',
visualKey: 'lime_leaf', visualKey: 'block-brown-slope-1x2',
colorClassName: 'from-lime-300 to-lime-600', colorClassName: 'from-orange-900 to-stone-700',
label: '', label: '棕色斜坡',
}, },
{ {
itemTypeId: 'white-moon', itemTypeId: 'block-sky-slope-2x2',
visualKey: 'white_moon', visualKey: 'block-sky-slope-2x2',
colorClassName: 'from-slate-100 to-slate-400', colorClassName: 'from-sky-300 to-sky-600',
label: '', label: '天蓝斜坡',
},
{
itemTypeId: 'block-green-cylinder',
visualKey: 'block-green-cylinder',
colorClassName: 'from-green-400 to-green-800',
label: '绿色圆柱',
},
{
itemTypeId: 'block-clear-ring',
visualKey: 'block-clear-ring',
colorClassName: 'from-slate-50 to-slate-300',
label: '透明圆环',
},
{
itemTypeId: 'block-mint-arch',
visualKey: 'block-mint-arch',
colorClassName: 'from-emerald-100 to-emerald-300',
label: '薄荷拱门',
},
{
itemTypeId: 'block-gold-cone',
visualKey: 'block-gold-cone',
colorClassName: 'from-yellow-300 to-amber-700',
label: '金色锥形件',
}, },
]; ];
function hashNumber(value: number) {
let state = Math.max(1, value >>> 0);
state ^= state << 13;
state ^= state >>> 7;
state ^= state << 17;
return state >>> 0;
}
function resolveSizeTierPlan(typeCount: number) {
const baseCounts = MATCH3D_SIZE_TIER_RULES.map((rule) => ({
...rule,
count: Math.floor(typeCount * rule.ratio),
remainder: typeCount * rule.ratio - Math.floor(typeCount * rule.ratio),
}));
let assignedCount = baseCounts.reduce((sum, rule) => sum + rule.count, 0);
const remainderOrder = [...baseCounts].sort(
(left, right) => right.remainder - left.remainder,
);
let cursor = 0;
while (assignedCount < typeCount) {
remainderOrder[cursor % remainderOrder.length]!.count += 1;
assignedCount += 1;
cursor += 1;
}
return baseCounts.flatMap((rule) => Array(rule.count).fill(rule));
}
function selectVisualSeeds(clearCount: number): Match3DSelectedVisualSeed[] {
const typeCount = Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, clearCount);
const seeds = [...MATCH3D_VISUAL_SEEDS];
let state = hashNumber(clearCount * 2_654_435_761);
for (let index = seeds.length - 1; index > 0; index -= 1) {
state = hashNumber(state + index);
const swapIndex = state % (index + 1);
[seeds[index], seeds[swapIndex]] = [seeds[swapIndex]!, seeds[index]!];
}
const sizeTierPlan = resolveSizeTierPlan(typeCount);
return seeds.slice(0, typeCount).map((seed, index) => ({
...seed,
radiusScale: sizeTierPlan[index]!.radiusScale,
relativeVolume: sizeTierPlan[index]!.relativeVolume,
sizeTier: sizeTierPlan[index]!.sizeTier,
}));
}
function createEmptyTray(): Match3DTraySlot[] { function createEmptyTray(): Match3DTraySlot[] {
return Array.from({ length: MATCH3D_TRAY_SLOT_COUNT }, (_, slotIndex) => ({ return Array.from({ length: MATCH3D_TRAY_SLOT_COUNT }, (_, slotIndex) => ({
slotIndex, slotIndex,
@@ -188,7 +269,7 @@ function normalizeRemainingMs(run: Match3DRunSnapshot, nowMs = Date.now()) {
} }
function buildItem( function buildItem(
seed: Match3DVisualSeed, seed: Match3DSelectedVisualSeed,
index: number, index: number,
copyIndex: number, copyIndex: number,
): Match3DItemSnapshot { ): Match3DItemSnapshot {
@@ -198,9 +279,7 @@ function buildItem(
const x = 0.5 + Math.cos(angle) * spread + ((index % 3) - 1) * 0.026; const x = 0.5 + Math.cos(angle) * spread + ((index % 3) - 1) * 0.026;
const y = const y =
0.5 + Math.sin(angle * 1.13) * spread + ((copyIndex % 3) - 1) * 0.02; 0.5 + Math.sin(angle * 1.13) * spread + ((copyIndex % 3) - 1) * 0.02;
const baseRadius = const radius = MATCH3D_LOCAL_BASE_RADIUS * seed.radiusScale;
0.072 - Math.min(0.018, ring * 0.004) + (copyIndex % 2) * 0.004;
const radius = baseRadius * (seed.sizeScale ?? 1);
return { return {
itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`, itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`,
itemTypeId: seed.itemTypeId, itemTypeId: seed.itemTypeId,
@@ -332,12 +411,12 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) {
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot { export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
const normalizedClearCount = Math.max(1, Math.round(clearCount)); const normalizedClearCount = Math.max(1, Math.round(clearCount));
const typeCount = Math.min(10, normalizedClearCount); const selectedSeeds = selectVisualSeeds(normalizedClearCount);
const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) => const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) =>
Array.from({ length: 3 }, (_, copyOffset) => { Array.from({ length: 3 }, (_, copyOffset) => {
const seed = const seed =
MATCH3D_VISUAL_SEEDS[clearIndex % typeCount] ?? selectedSeeds[clearIndex % selectedSeeds.length] ??
MATCH3D_VISUAL_SEEDS[0]!; selectedSeeds[0]!;
return buildItem( return buildItem(
seed, seed,
clearIndex * 3 + copyOffset, clearIndex * 3 + copyOffset,

View File

@@ -1,5 +1,6 @@
import type { import type {
CreateProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse,
ClaimProfileTaskRewardResponse,
PlatformBrowseHistoryBatchSyncRequest, PlatformBrowseHistoryBatchSyncRequest,
PlatformBrowseHistoryResponse, PlatformBrowseHistoryResponse,
PlatformBrowseHistoryWriteEntry, PlatformBrowseHistoryWriteEntry,
@@ -9,6 +10,7 @@ import type {
ProfileRechargeCenterResponse, ProfileRechargeCenterResponse,
ProfileSaveArchiveListResponse, ProfileSaveArchiveListResponse,
ProfileSaveArchiveResumeResponse, ProfileSaveArchiveResumeResponse,
ProfileTaskCenterResponse,
ProfileWalletLedgerResponse, ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeResponse, RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse, RedeemProfileRewardCodeResponse,
@@ -142,6 +144,27 @@ export function redeemRpgProfileRewardCode(
); );
} }
export function getRpgProfileTasks(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfileTaskCenterResponse>(
'/profile/tasks',
{ method: 'GET' },
'读取每日任务失败',
options,
);
}
export function claimRpgProfileTaskReward(
taskId: string,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<ClaimProfileTaskRewardResponse>(
`/profile/tasks/${encodeURIComponent(taskId)}/claim`,
{ method: 'POST' },
'领取任务奖励失败',
options,
);
}
export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) { export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfilePlayStatsResponse>( return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
'/profile/play-stats', '/profile/play-stats',
@@ -255,6 +278,8 @@ export const rpgProfileClient = {
createRechargeOrder: createRpgProfileRechargeOrder, createRechargeOrder: createRpgProfileRechargeOrder,
getReferralInviteCenter: getRpgProfileReferralInviteCenter, getReferralInviteCenter: getRpgProfileReferralInviteCenter,
redeemReferralInviteCode: redeemRpgProfileReferralInviteCode, redeemReferralInviteCode: redeemRpgProfileReferralInviteCode,
getTasks: getRpgProfileTasks,
claimTaskReward: claimRpgProfileTaskReward,
getSettings: getRpgProfileSettings, getSettings: getRpgProfileSettings,
putSettings: putRpgProfileSettings, putSettings: putRpgProfileSettings,
listSaveArchives: listRpgProfileSaveArchives, listSaveArchives: listRpgProfileSaveArchives,

View File

@@ -33,6 +33,9 @@ export default defineConfig(({mode}) => {
`http://127.0.0.1:${env.GENARRATIVE_API_PORT || '3100'}`; `http://127.0.0.1:${env.GENARRATIVE_API_PORT || '3100'}`;
const runtimeServerTarget = const runtimeServerTarget =
env.GENARRATIVE_RUNTIME_SERVER_TARGET || rustServerTarget; env.GENARRATIVE_RUNTIME_SERVER_TARGET || rustServerTarget;
const adminWebTarget =
env.ADMIN_WEB_TARGET ||
`http://127.0.0.1:${env.ADMIN_WEB_PORT || '3102'}`;
return { return {
root: __dirname, root: __dirname,
@@ -62,6 +65,11 @@ export default defineConfig(({mode}) => {
// Do not modify; file watching is disabled to prevent flickering during agent edits. // Do not modify; file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true', hmr: process.env.DISABLE_HMR !== 'true',
proxy: { proxy: {
'/admin/': {
target: adminWebTarget,
changeOrigin: true,
secure: false,
},
'/api/auth': { '/api/auth': {
target: runtimeServerTarget, target: runtimeServerTarget,
changeOrigin: true, changeOrigin: true,