Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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."
|
||||||
@@ -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`。
|
||||||
|
|
||||||
构建生产包:
|
构建生产包:
|
||||||
|
|||||||
@@ -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(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
45
apps/admin-web/src/config/trackingEventDefinitions.ts
Normal file
45
apps/admin-web/src/config/trackingEventDefinitions.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
545
apps/admin-web/src/pages/AdminTaskConfigPage.tsx
Normal file
545
apps/admin-web/src/pages/AdminTaskConfigPage.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
24
deploy/systemd/jenkins-agent@.service
Normal file
24
deploy/systemd/jenkins-agent@.service
Normal 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
|
||||||
@@ -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/`:偏后台运营核查、对账和排障查询。
|
||||||
|
|||||||
33
docs/operations/PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md
Normal file
33
docs/operations/PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md
Normal 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` 中查到。
|
||||||
5
docs/operations/README.md
Normal file
5
docs/operations/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 运营查询
|
||||||
|
|
||||||
|
本目录存放后台运营核查、对账和排障查询,不承载埋点系统本身的查询手册。
|
||||||
|
|
||||||
|
- [PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md](./PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md):个人任务配置、进度、领奖记录与钱包流水对账查询。
|
||||||
@@ -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 胜利
|
||||||
|
|
||||||
|
|||||||
@@ -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 后端边界
|
||||||
|
|
||||||
|
|||||||
@@ -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 脚本。
|
||||||
|
|||||||
@@ -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 口径
|
||||||
|
|
||||||
|
|||||||
@@ -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. 前端本地试玩、创作后试玩和后端权威运行态必须使用同一套五档体积分配口径。
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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 契约和前端入口同步更新。
|
||||||
@@ -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 请求超时后的本地养成档案兜底,避免底稿主链被尾部角色润色阶段阻断。
|
||||||
|
|||||||
@@ -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
7
docs/tracking/README.md
Normal 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/`。
|
||||||
50
docs/tracking/TRACKING_QUERY_PLAYBOOK_2026-05-03.md
Normal file
50
docs/tracking/TRACKING_QUERY_PLAYBOOK_2026-05-03.md
Normal 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"
|
||||||
|
```
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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\` 仅供无域名开发服初始化使用。
|
||||||
|
|
||||||
## 生产部署口径
|
## 生产部署口径
|
||||||
|
|||||||
218
scripts/deploy/install-jenkins-inbound-agent.sh
Normal file
218
scripts/deploy/install-jenkins-inbound-agent.sh
Normal 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}"
|
||||||
50
scripts/deploy/jenkins-agent-reverse-tunnel.ps1
Normal file
50
scripts/deploy/jenkins-agent-reverse-tunnel.ps1
Normal 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
|
||||||
|
}
|
||||||
80
scripts/deploy/jenkins-inbound-agent-start.sh
Normal file
80
scripts/deploy/jenkins-inbound-agent-start.sh
Normal 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[@]}"
|
||||||
95
scripts/deploy/jenkins-local-controller-watchdog.ps1
Normal file
95
scripts/deploy/jenkins-local-controller-watchdog.ps1
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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))]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 不能为空"),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ pub enum RuntimeProfileWalletLedgerSourceType {
|
|||||||
RedeemCodeReward,
|
RedeemCodeReward,
|
||||||
|
|
||||||
PuzzleAuthorIncentiveClaim,
|
PuzzleAuthorIncentiveClaim,
|
||||||
|
|
||||||
|
DailyTaskReward,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {
|
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user