This commit is contained in:
zhx 2026-06-18 19:38:25 +08:00
parent 743e4dc1c8
commit d876ff6985
12 changed files with 1240 additions and 549 deletions

View File

@ -154,6 +154,30 @@
"x-permissions": ["cp-config:update"] "x-permissions": ["cp-config:update"]
} }
}, },
"/admin/activity/cp/relationships": {
"get": {
"operationId": "listCPRelationships",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "cp-config:view",
"x-permissions": ["cp-config:view"]
}
},
"/admin/activity/cp/applications": {
"get": {
"operationId": "listCPApplications",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "cp-config:view",
"x-permissions": ["cp-config:view"]
}
},
"/admin/activity/red-packets": { "/admin/activity/red-packets": {
"get": { "get": {
"operationId": "listRedPackets", "operationId": "listRedPackets",

View File

@ -88,6 +88,30 @@ test("renders cp config route with an authenticated session", async () => {
], ],
}); });
} }
if (url.includes("/v1/admin/activity/cp/relationships")) {
return jsonResponse({
items: [
{
relationshipId: "cp_1001_1002",
relationType: "cp",
status: "active",
userA: { displayUserId: "1001", userId: "1001", username: "A" },
userB: { displayUserId: "1002", userId: "1002", username: "B" },
intimacyValue: 12000,
level: 2,
durationDays: 3,
gift: { giftId: "rose", giftName: "Rose", giftCount: 1, giftValue: 100 },
formedAtMs: 1760000000000,
},
],
page: 1,
pageSize: 50,
total: 1,
});
}
if (url.includes("/v1/admin/activity/cp/applications")) {
return jsonResponse({ items: [], page: 1, pageSize: 50, total: 0 });
}
return jsonResponse(null); return jsonResponse(null);
}); });

View File

@ -1,7 +1,66 @@
import { apiRequest } from "@/shared/api/request"; import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiPage, PageQuery } from "@/shared/api/types";
export type CPRelationType = "cp" | "brother" | "sister"; export type CPRelationType = "cp" | "brother" | "sister";
export interface CPUserProfileDto {
userId: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
username?: string;
avatar?: string;
}
export interface CPGiftSnapshotDto {
giftId: string;
giftName: string;
giftIconUrl: string;
giftAnimationUrl: string;
giftCount: number;
giftValue: number;
billingReceiptId: string;
}
export interface CPRelationshipDto {
relationshipId: string;
relationType: CPRelationType;
status: string;
userA: CPUserProfileDto;
userB: CPUserProfileDto;
intimacyValue: number;
level: number;
durationDays: number;
gift?: CPGiftSnapshotDto;
formedAtMs: number;
updatedAtMs: number;
endedAtMs: number;
}
export interface CPApplicationDto {
applicationId: string;
relationType: CPRelationType;
status: string;
requester: CPUserProfileDto;
target: CPUserProfileDto;
gift: CPGiftSnapshotDto;
roomId: string;
roomRegionId: number;
createdAtMs: number;
updatedAtMs: number;
expiresAtMs: number;
decidedAtMs: number;
}
export interface CPRelationshipPageDto extends ApiPage<CPRelationshipDto> {
serverTimeMs?: number;
}
export interface CPApplicationPageDto extends ApiPage<CPApplicationDto> {
serverTimeMs?: number;
}
export interface CPLevelDto { export interface CPLevelDto {
level: number; level: number;
intimacyThreshold: number; intimacyThreshold: number;
@ -66,20 +125,55 @@ export interface CPConfigPayload {
}; };
} }
const path = "/v1/admin/activity/cp/config";
export function getCPConfig(): Promise<CPConfigDto> { export function getCPConfig(): Promise<CPConfigDto> {
return apiRequest<RawConfig>(path).then(normalizeConfig); const endpoint = API_ENDPOINTS.getCPConfig;
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getCPConfig), { method: endpoint.method }).then(
normalizeConfig,
);
} }
export function updateCPConfig(payload: CPConfigPayload): Promise<CPConfigDto> { export function updateCPConfig(payload: CPConfigPayload): Promise<CPConfigDto> {
return apiRequest<RawConfig, CPConfigPayload>(path, { body: payload, method: "PUT" }).then(normalizeConfig); const endpoint = API_ENDPOINTS.updateCPConfig;
return apiRequest<RawConfig, CPConfigPayload>(apiEndpointPath(API_OPERATIONS.updateCPConfig), {
body: payload,
method: endpoint.method,
}).then(normalizeConfig);
}
export function listCPRelationships(query: PageQuery = {}): Promise<CPRelationshipPageDto> {
const endpoint = API_ENDPOINTS.listCPRelationships;
return apiRequest<RawRelationshipPage>(apiEndpointPath(API_OPERATIONS.listCPRelationships), {
method: endpoint.method,
query,
}).then(normalizeRelationshipPage);
}
export function listCPApplications(query: PageQuery = {}): Promise<CPApplicationPageDto> {
const endpoint = API_ENDPOINTS.listCPApplications;
return apiRequest<RawApplicationPage>(apiEndpointPath(API_OPERATIONS.listCPApplications), {
method: endpoint.method,
query,
}).then(normalizeApplicationPage);
} }
type RawConfig = CPConfigDto & Record<string, unknown>; type RawConfig = CPConfigDto & Record<string, unknown>;
type RawRelation = CPRelationConfigDto & Record<string, unknown>; type RawRelation = CPRelationConfigDto & Record<string, unknown>;
type RawLevel = CPLevelDto & Record<string, unknown>; type RawLevel = CPLevelDto & Record<string, unknown>;
type RawWeeklyRank = CPWeeklyRankDto & Record<string, unknown>; type RawWeeklyRank = CPWeeklyRankDto & Record<string, unknown>;
type RawRelationship = CPRelationshipDto & Record<string, unknown>;
type RawApplication = CPApplicationDto & Record<string, unknown>;
type RawUser = CPUserProfileDto & Record<string, unknown>;
type RawGift = CPGiftSnapshotDto & Record<string, unknown>;
type RawRelationshipPage = ApiPage<RawRelationship> & {
relationships?: RawRelationship[];
page_size?: number | string;
server_time_ms?: number | string;
} & Record<string, unknown>;
type RawApplicationPage = ApiPage<RawApplication> & {
applications?: RawApplication[];
page_size?: number | string;
server_time_ms?: number | string;
} & Record<string, unknown>;
function normalizeConfig(raw: RawConfig): CPConfigDto { function normalizeConfig(raw: RawConfig): CPConfigDto {
const item = asRecord(raw) as RawConfig; const item = asRecord(raw) as RawConfig;
@ -137,6 +231,96 @@ function normalizeLevel(raw: unknown): CPLevelDto {
}; };
} }
function normalizeRelationshipPage(page: RawRelationshipPage): CPRelationshipPageDto {
const response = asRecord(page) as RawRelationshipPage;
const items = arrayValue(response.items ?? response.relationships).map(normalizeRelationship);
return {
items,
page: numberValue(response.page) || 1,
pageSize: numberValue(response.pageSize ?? response.page_size) || items.length || 50,
total: numberValue(response.total),
serverTimeMs: numberValue(response.serverTimeMs ?? response.server_time_ms),
};
}
function normalizeApplicationPage(page: RawApplicationPage): CPApplicationPageDto {
const response = asRecord(page) as RawApplicationPage;
const items = arrayValue(response.items ?? response.applications).map(normalizeApplication);
return {
items,
page: numberValue(response.page) || 1,
pageSize: numberValue(response.pageSize ?? response.page_size) || items.length || 50,
total: numberValue(response.total),
serverTimeMs: numberValue(response.serverTimeMs ?? response.server_time_ms),
};
}
function normalizeRelationship(raw: unknown): CPRelationshipDto {
const item = asRecord(raw) as RawRelationship;
const userA = normalizeUser(item.userA ?? item.user_a ?? item.me ?? item.requester);
const userB = normalizeUser(item.userB ?? item.user_b ?? item.partner ?? item.target);
const gift = normalizeGift(item.gift ?? item.sourceGift ?? item.source_gift ?? item.applicationGift ?? item.application_gift);
return {
relationshipId: stringValue(item.relationshipId ?? item.relationship_id),
relationType: normalizeRelationType(item.relationType ?? item.relation_type),
status: stringValue(item.status) || "active",
userA,
userB,
intimacyValue: numberValue(item.intimacyValue ?? item.intimacy_value ?? item.experienceValue ?? item.experience_value),
level: numberValue(item.level ?? item.levelNo ?? item.level_no),
durationDays: numberValue(item.durationDays ?? item.duration_days ?? item.days),
gift: gift.giftId || gift.giftName || gift.giftValue ? gift : undefined,
formedAtMs: numberValue(item.formedAtMs ?? item.formed_at_ms ?? item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
endedAtMs: numberValue(item.endedAtMs ?? item.ended_at_ms),
};
}
function normalizeApplication(raw: unknown): CPApplicationDto {
const item = asRecord(raw) as RawApplication;
return {
applicationId: stringValue(item.applicationId ?? item.application_id),
relationType: normalizeRelationType(item.relationType ?? item.relation_type),
status: stringValue(item.status) || "pending",
requester: normalizeUser(item.requester ?? item.requesterUser ?? item.requester_user ?? item.userA ?? item.user_a),
target: normalizeUser(item.target ?? item.targetUser ?? item.target_user ?? item.userB ?? item.user_b),
gift: normalizeGift(item.gift ?? item.sourceGift ?? item.source_gift),
roomId: stringValue(item.roomId ?? item.room_id),
roomRegionId: numberValue(item.roomRegionId ?? item.room_region_id),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
decidedAtMs: numberValue(item.decidedAtMs ?? item.decided_at_ms),
};
}
function normalizeUser(raw: unknown): CPUserProfileDto {
const item = asRecord(raw) as RawUser;
return {
userId: stringValue(item.userId ?? item.user_id),
displayUserId: optionalString(item.displayUserId ?? item.display_user_id),
prettyDisplayUserId: optionalString(item.prettyDisplayUserId ?? item.pretty_display_user_id),
prettyId: optionalString(item.prettyId ?? item.pretty_id),
username: optionalString(item.username ?? item.name),
avatar: optionalString(item.avatar),
};
}
function normalizeGift(raw: unknown): CPGiftSnapshotDto {
const item = asRecord(raw) as RawGift;
return {
giftId: stringValue(item.giftId ?? item.gift_id),
giftName: stringValue(item.giftName ?? item.gift_name ?? item.name),
giftIconUrl: stringValue(item.giftIconUrl ?? item.gift_icon_url ?? item.iconUrl ?? item.icon_url),
giftAnimationUrl: stringValue(
item.giftAnimationUrl ?? item.gift_animation_url ?? item.animationUrl ?? item.animation_url,
),
giftCount: numberValue(item.giftCount ?? item.gift_count ?? item.count),
giftValue: numberValue(item.giftValue ?? item.gift_value ?? item.value),
billingReceiptId: stringValue(item.billingReceiptId ?? item.billing_receipt_id),
};
}
function normalizeRelationType(value: unknown): CPRelationType { function normalizeRelationType(value: unknown): CPRelationType {
const relationType = stringValue(value).toLowerCase(); const relationType = stringValue(value).toLowerCase();
if (relationType === "brother" || relationType === "sister") { if (relationType === "brother" || relationType === "sister") {
@ -161,6 +345,11 @@ function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value); return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
} }
function optionalString(value: unknown) {
const text = stringValue(value).trim();
return text || undefined;
}
function numberValue(value: unknown) { function numberValue(value: unknown) {
const number = Number(value || 0); const number = Number(value || 0);
return Number.isFinite(number) ? number : 0; return Number.isFinite(number) ? number : 0;

View File

@ -38,6 +38,77 @@
gap: 10px; gap: 10px;
} }
.tabsBar {
display: flex;
align-items: center;
min-height: 44px;
padding: 0 2px;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
}
.tab {
min-height: 40px !important;
padding-right: 18px !important;
padding-left: 18px !important;
color: var(--text-secondary) !important;
font-weight: 700 !important;
text-transform: none !important;
}
.giftCell {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
gap: var(--space-3);
}
.giftIcon {
width: 36px;
height: 36px;
flex: 0 0 auto;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card-strong);
object-fit: cover;
}
.amount {
color: var(--text-primary);
font-size: 12px;
font-weight: 760;
white-space: nowrap;
}
.relationBadge {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 999px;
color: var(--text-secondary);
background: var(--bg-card-strong);
font-size: 12px;
font-weight: 800;
}
.relationBadge_cp {
color: #be123c;
background: #ffe4e6;
}
.relationBadge_brother {
color: #1d4ed8;
background: #dbeafe;
}
.relationBadge_sister {
color: #a21caf;
background: #fae8ff;
}
.stack { .stack {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -70,6 +141,16 @@
background: #e2e8f0; background: #e2e8f0;
} }
.statusPending {
color: #92400e;
background: #fef3c7;
}
.statusDanger {
color: #b91c1c;
background: #fee2e2;
}
.levelPreview { .levelPreview {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -95,31 +176,44 @@
gap: 16px; gap: 16px;
} }
.configDrawer { .configDialogPaper {
box-sizing: border-box; width: min(1040px, calc(100vw - 32px)) !important;
width: min(980px, calc(100vw - 48px)); max-width: min(1040px, calc(100vw - 32px)) !important;
height: 100vh; border-radius: var(--radius-card);
max-height: 100vh; background: var(--bg-card);
grid-template-rows: auto minmax(0, 1fr) auto; box-shadow: var(--shadow-dialog);
overflow: hidden;
padding: var(--space-5);
} }
.configDrawerHeader { .configDialogForm {
display: grid;
max-height: calc(100vh - 56px);
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
}
.configDialogTitle {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-4); gap: var(--space-4);
padding-bottom: var(--space-3); padding: var(--space-5) var(--space-5) var(--space-3) !important;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.configDrawerHeader h2 { .configDialogTitle h2 {
margin: 0; margin: 0;
font-size: 20px; font-size: 20px;
font-weight: 800; font-weight: 800;
} }
.configDialogTitle span {
display: inline-flex;
margin-top: 4px;
color: var(--text-tertiary);
font-size: 12px;
font-weight: 650;
}
.configDrawerStats { .configDrawerStats {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -140,14 +234,18 @@
font-weight: 700; font-weight: 700;
} }
.configDrawerBody { .configDialogContent {
display: grid;
gap: 18px;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
padding-right: 2px; padding: var(--space-5) !important;
} }
.configDrawerActions { .configDialogActions {
padding-top: var(--space-3); justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-3) var(--space-5) var(--space-5) !important;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
background: var(--bg-card); background: var(--bg-card);
} }
@ -238,14 +336,28 @@
justify-content: flex-end; justify-content: flex-end;
} }
.configDrawer { .tabsBar {
width: 100vw; overflow-x: auto;
padding: var(--space-4);
} }
.configDrawerHeader { .configDialogPaper {
width: calc(100vw - 24px) !important;
max-width: calc(100vw - 24px) !important;
margin: 12px !important;
}
.configDialogTitle {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
padding: var(--space-4) var(--space-4) var(--space-3) !important;
}
.configDialogContent {
padding: var(--space-4) !important;
}
.configDialogActions {
padding: var(--space-3) var(--space-4) var(--space-4) !important;
} }
.levelEditorList, .levelEditorList,

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { getCPConfig, updateCPConfig } from "@/features/cp-config/api"; import { getCPConfig, listCPApplications, listCPRelationships, updateCPConfig } from "@/features/cp-config/api";
import { useCPConfigAbilities } from "@/features/cp-config/permissions.js"; import { useCPConfigAbilities } from "@/features/cp-config/permissions.js";
import { listResourceGroups } from "@/features/resources/api"; import { listResourceGroups } from "@/features/resources/api";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
@ -14,10 +14,20 @@ export const cpWeeklyRankRanks = [1, 2, 3];
const defaultExpireHours = 24; const defaultExpireHours = 24;
const defaultThresholds = [0, 10000, 30000, 60000, 100000]; const defaultThresholds = [0, 10000, 30000, 60000, 100000];
const listPageSize = 50;
const emptyListPage = () => ({ items: [], page: 1, pageSize: listPageSize, serverTimeMs: 0, total: 0 });
export function useCPConfigPage() { export function useCPConfigPage() {
const abilities = useCPConfigAbilities(); const abilities = useCPConfigAbilities();
const { showToast } = useToast(); const { showToast } = useToast();
const [activeTab, setActiveTabValue] = useState("relationships");
const [relationships, setRelationships] = useState(emptyListPage);
const [applications, setApplications] = useState(emptyListPage);
const [relationshipsLoading, setRelationshipsLoading] = useState(false);
const [applicationsLoading, setApplicationsLoading] = useState(false);
const [relationshipsError, setRelationshipsError] = useState("");
const [applicationsError, setApplicationsError] = useState("");
const [applicationsLoaded, setApplicationsLoaded] = useState(false);
const [config, setConfig] = useState({ relations: defaultRelations(), weeklyRank: defaultWeeklyRank() }); const [config, setConfig] = useState({ relations: defaultRelations(), weeklyRank: defaultWeeklyRank() });
const [form, setForm] = useState({ const [form, setForm] = useState({
relations: defaultRelations().map(relationToForm), relations: defaultRelations().map(relationToForm),
@ -33,7 +43,7 @@ export function useCPConfigPage() {
[config.relations], [config.relations],
); );
const reload = useCallback(async () => { const reloadConfig = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const [configResult, groupResult] = await Promise.allSettled([ const [configResult, groupResult] = await Promise.allSettled([
@ -62,8 +72,98 @@ export function useCPConfigPage() {
}, [showToast]); }, [showToast]);
useEffect(() => { useEffect(() => {
void reload(); void reloadConfig();
}, [reload]); }, [reloadConfig]);
const loadRelationships = useCallback(
async (page = 1) => {
const normalizedPage = Number(page || 1);
if (normalizedPage <= 1) {
setRelationshipsLoading(true);
setRelationshipsError("");
}
try {
const result = await listCPRelationships({ page: normalizedPage, page_size: listPageSize });
setRelationships((current) =>
normalizedPage > 1
? { ...result, items: [...(current.items || []), ...(result.items || [])] }
: result,
);
} catch (err) {
const message = err.message || "加载用户关系列表失败";
if (normalizedPage <= 1) {
setRelationshipsError(message);
} else {
showToast(message, "error");
}
} finally {
if (normalizedPage <= 1) {
setRelationshipsLoading(false);
}
}
},
[showToast],
);
const loadApplications = useCallback(
async (page = 1) => {
const normalizedPage = Number(page || 1);
if (normalizedPage <= 1) {
setApplicationsLoading(true);
setApplicationsError("");
}
try {
const result = await listCPApplications({ page: normalizedPage, page_size: listPageSize });
setApplications((current) =>
normalizedPage > 1
? { ...result, items: [...(current.items || []), ...(result.items || [])] }
: result,
);
setApplicationsLoaded(true);
} catch (err) {
const message = err.message || "加载 CP 申请列表失败";
if (normalizedPage <= 1) {
setApplicationsError(message);
} else {
showToast(message, "error");
}
} finally {
if (normalizedPage <= 1) {
setApplicationsLoading(false);
}
}
},
[showToast],
);
useEffect(() => {
void loadRelationships(1);
}, [loadRelationships]);
const setActiveTab = useCallback(
(tab) => {
setActiveTabValue(tab);
if (tab === "applications" && !applicationsLoaded) {
void loadApplications(1);
}
},
[applicationsLoaded, loadApplications],
);
const reloadActiveTab = useCallback(() => {
if (activeTab === "applications") {
void loadApplications(1);
return;
}
void loadRelationships(1);
}, [activeTab, loadApplications, loadRelationships]);
const reload = useCallback(async () => {
await Promise.allSettled([
reloadConfig(),
activeTab === "applications" ? loadApplications(1) : loadRelationships(1),
]);
}, [activeTab, loadApplications, loadRelationships, reloadConfig]);
const openDrawer = () => { const openDrawer = () => {
setForm({ setForm({
@ -158,16 +258,27 @@ export function useCPConfigPage() {
return { return {
abilities, abilities,
activeTab,
activeRelationCount, activeRelationCount,
applications,
applicationsError,
applicationsLoading,
closeDrawer, closeDrawer,
config, config,
drawerOpen, drawerOpen,
form, form,
loading, loading,
loadApplications,
loadRelationships,
openDrawer, openDrawer,
reload, reload,
reloadActiveTab,
relationships,
relationshipsError,
relationshipsLoading,
resourceGroups, resourceGroups,
saving, saving,
setActiveTab,
submit, submit,
updateLevel, updateLevel,
updateRelation, updateRelation,

View File

@ -1,23 +1,44 @@
import EditOutlined from "@mui/icons-material/EditOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined"; import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Drawer from "@mui/material/Drawer"; import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import CircularProgress from "@mui/material/CircularProgress";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { useMemo } from "react"; import { useMemo } from "react";
import { cpRelationLabels, cpWeeklyRankRanks, useCPConfigPage } from "@/features/cp-config/hooks/useCPConfigPage.js"; import { cpRelationLabels, cpWeeklyRankRanks, useCPConfigPage } from "@/features/cp-config/hooks/useCPConfigPage.js";
import styles from "@/features/cp-config/cp-config.module.css"; import styles from "@/features/cp-config/cp-config.module.css";
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx"; import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx"; import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
import { formatMillis } from "@/shared/utils/time.js"; import { TimeText } from "@/shared/ui/TimeText.jsx";
const tabLabels = {
relationships: "用户关系列表",
applications: "CP申请列表",
};
export function CPConfigPage() { export function CPConfigPage() {
const page = useCPConfigPage(); const page = useCPConfigPage();
const relations = page.config.relations || []; const relations = page.config.relations || [];
const columns = useMemo(() => cpConfigColumns(), []); const relationshipColumns = useMemo(
() => cpRelationshipColumns(page.relationships.serverTimeMs),
[page.relationships.serverTimeMs],
);
const applicationColumns = useMemo(() => cpApplicationColumns(), []);
const isApplicationsTab = page.activeTab === "applications";
const activePage = isApplicationsTab ? page.applications : page.relationships;
const activeLoading = isApplicationsTab ? page.applicationsLoading : page.relationshipsLoading;
const activeError = isApplicationsTab ? page.applicationsError : page.relationshipsError;
return ( return (
<AdminListPage> <AdminListPage>
@ -34,52 +55,76 @@ export function CPConfigPage() {
</div> </div>
<div className={styles.summaryActions}> <div className={styles.summaryActions}>
<Button <Button
disabled={page.loading} disabled={page.loading || activeLoading}
startIcon={<RefreshOutlined fontSize="small" />} startIcon={<RefreshOutlined fontSize="small" />}
onClick={page.reload} onClick={page.reload}
> >
刷新 刷新
</Button> </Button>
{page.abilities.canUpdate ? ( <Button
<Button disabled={page.loading}
disabled={page.loading} startIcon={page.abilities.canUpdate ? <EditOutlined fontSize="small" /> : <SettingsOutlined fontSize="small" />}
startIcon={<EditOutlined fontSize="small" />} variant="primary"
variant="primary" onClick={page.openDrawer}
onClick={page.openDrawer} >
> 配置
编辑配置 </Button>
</Button>
) : null}
</div> </div>
</div> </div>
<DataState loading={page.loading} onRetry={page.reload}> <div className={styles.tabsBar}>
<Tabs value={page.activeTab} onChange={(_, value) => page.setActiveTab(value)}>
<Tab className={styles.tab} label={tabLabels.relationships} value="relationships" />
<Tab className={styles.tab} label={tabLabels.applications} value="applications" />
</Tabs>
</div>
<DataState error={activeError} loading={activeLoading} onRetry={page.reloadActiveTab}>
<AdminListBody> <AdminListBody>
<DataTable <DataTable
columns={columns} columns={isApplicationsTab ? applicationColumns : relationshipColumns}
items={relations} emptyLabel="当前无数据"
minWidth="1120px" items={activePage.items}
rowKey={(item) => item.relationType} minWidth={isApplicationsTab ? "1280px" : "1440px"}
pagination={{
page: activePage.page,
pageSize: activePage.pageSize,
total: activePage.total,
onPageChange: isApplicationsTab ? page.loadApplications : page.loadRelationships,
}}
rowKey={(item) =>
isApplicationsTab ? item.applicationId : item.relationshipId || `${item.userA?.userId}-${item.userB?.userId}`
}
total={activePage.total}
/> />
</AdminListBody> </AdminListBody>
</DataState> </DataState>
<CPConfigDrawer page={page} /> <CPConfigDialog page={page} />
</AdminListPage> </AdminListPage>
); );
} }
function CPConfigDrawer({ page }) { function CPConfigDialog({ page }) {
const disabled = !page.abilities.canUpdate || page.saving || page.loading; const canUpdate = page.abilities.canUpdate;
const disabled = !canUpdate || page.saving || page.loading;
return ( return (
<Drawer anchor="right" open={page.drawerOpen} onClose={page.saving ? undefined : page.closeDrawer}> <Dialog
<form className={["form-drawer", styles.configDrawer].join(" ")} onSubmit={page.submit}> className={styles.configDialog}
<div className={styles.configDrawerHeader}> maxWidth={false}
<h2>CP配置</h2> open={page.drawerOpen}
slotProps={{ paper: { className: styles.configDialogPaper } }}
onClose={page.saving ? undefined : page.closeDrawer}
>
<form className={styles.configDialogForm} onSubmit={page.submit}>
<DialogTitle className={styles.configDialogTitle}>
<div>
<h2>CP配置</h2>
<span>关系等级解除费用和 CP 周榜奖励</span>
</div>
<div className={styles.configDrawerStats}> <div className={styles.configDrawerStats}>
<span>{(page.form.relations || []).length} 个关系</span> <span>{(page.form.relations || []).length} 个关系</span>
<span>{page.activeRelationCount} 个启用</span> <span>{page.activeRelationCount} 个启用</span>
</div> </div>
</div> </DialogTitle>
<div className={styles.configDrawerBody}> <DialogContent className={styles.configDialogContent}>
<section className="form-drawer__section"> <section className="form-drawer__section">
<div className="form-drawer__section-title">关系与等级</div> <div className="form-drawer__section-title">关系与等级</div>
<div className={styles.relationEditorList}> <div className={styles.relationEditorList}>
@ -105,22 +150,24 @@ function CPConfigDrawer({ page }) {
updateWeeklyRankReward={page.updateWeeklyRankReward} updateWeeklyRankReward={page.updateWeeklyRankReward}
/> />
</section> </section>
</div> </DialogContent>
<div className={["form-drawer__actions", styles.configDrawerActions].join(" ")}> <DialogActions className={styles.configDialogActions}>
<Button disabled={page.saving} type="button" onClick={page.closeDrawer}> <Button disabled={page.saving} type="button" onClick={page.closeDrawer}>
取消 {canUpdate ? "取消" : "关闭"}
</Button> </Button>
<Button {canUpdate ? (
disabled={disabled} <Button
startIcon={<SaveOutlined fontSize="small" />} disabled={disabled}
type="submit" startIcon={page.saving ? <CircularProgress color="inherit" size={16} /> : <SaveOutlined fontSize="small" />}
variant="primary" type="submit"
> variant="primary"
保存 >
</Button> 保存
</div> </Button>
) : null}
</DialogActions>
</form> </form>
</Drawer> </Dialog>
); );
} }
@ -309,42 +356,55 @@ function LevelEditor({ disabled, level, relationType, resourceGroups, updateLeve
); );
} }
function cpConfigColumns() { function cpRelationshipColumns(serverTimeMs) {
return [ return [
{ {
key: "relation", key: "userA",
label: "关系", label: "用户A",
width: "minmax(140px, 0.45fr)", width: "minmax(210px, 0.9fr)",
render: (item) => ( render: (item) => <CPUserCell user={item.userA} />,
<div className={styles.stack}>
<span className={styles.name}>{item.displayName || cpRelationLabels[item.relationType]}</span>
<span className={styles.meta}>{item.relationType}</span>
</div>
),
}, },
{ {
key: "maxCount", key: "userB",
label: "数量上限", label: "用户B",
width: "minmax(120px, 0.45fr)", width: "minmax(210px, 0.9fr)",
render: (item) => (item.relationType === "cp" ? "1固定" : formatNumber(item.maxCountPerUser)), render: (item) => <CPUserCell user={item.userB} />,
}, },
{ {
key: "expire", key: "relationType",
label: "申请过期", label: "类型",
width: "minmax(130px, 0.45fr)", width: "minmax(110px, 0.4fr)",
render: (item) => `${formatNumber(item.applicationExpireHours)} 小时`, render: (item) => <RelationTypeBadge relationType={item.relationType} />,
}, },
{ {
key: "breakupCost", key: "days",
label: "解除费用", label: "天数",
width: "minmax(130px, 0.45fr)", width: "minmax(90px, 0.35fr)",
render: (item) => `${formatNumber(item.breakupCostCoins)} 金币`, render: (item) => formatRelationDays(item, serverTimeMs),
}, },
{ {
key: "levels", key: "level",
label: "等级亲密值", label: "等级",
width: "minmax(360px, 1.2fr)", width: "minmax(90px, 0.35fr)",
render: (item) => <LevelPreview levels={item.levels || []} />, render: (item) => (item.level ? `${formatNumber(item.level)}` : "-"),
},
{
key: "intimacy",
label: "经验值",
width: "minmax(130px, 0.5fr)",
render: (item) => formatNumber(item.intimacyValue),
},
{
key: "gift",
label: "关系礼物/价值",
width: "minmax(240px, 0.95fr)",
render: (item) => <GiftCell gift={item.gift} />,
},
{
key: "formedAt",
label: "组成时间",
width: "minmax(170px, 0.7fr)",
render: (item) => <NullableTime value={item.formedAtMs} />,
}, },
{ {
key: "status", key: "status",
@ -352,36 +412,102 @@ function cpConfigColumns() {
width: "minmax(110px, 0.4fr)", width: "minmax(110px, 0.4fr)",
render: (item) => <StatusBadge status={item.status} />, render: (item) => <StatusBadge status={item.status} />,
}, },
];
}
function cpApplicationColumns() {
return [
{ {
key: "updatedAt", key: "requester",
label: "更新时间", label: "申请方",
width: "minmax(180px, 0.6fr)", width: "minmax(210px, 0.9fr)",
render: (item) => (item.updatedAtMs ? formatMillis(item.updatedAtMs) : "-"), render: (item) => <CPUserCell user={item.requester} />,
},
{
key: "target",
label: "接收方",
width: "minmax(210px, 0.9fr)",
render: (item) => <CPUserCell user={item.target} />,
},
{
key: "relationType",
label: "申请类型",
width: "minmax(120px, 0.45fr)",
render: (item) => <RelationTypeBadge relationType={item.relationType} />,
},
{
key: "gift",
label: "申请礼物/价值",
width: "minmax(240px, 1fr)",
render: (item) => <GiftCell gift={item.gift} />,
},
{
key: "status",
label: "状态",
width: "minmax(110px, 0.45fr)",
render: (item) => <StatusBadge status={item.status} />,
},
{
key: "createdAt",
label: "申请时间",
width: "minmax(170px, 0.7fr)",
render: (item) => <NullableTime value={item.createdAtMs} />,
},
{
key: "decidedAt",
label: "处理时间",
width: "minmax(170px, 0.7fr)",
render: (item) => <NullableTime value={item.decidedAtMs} />,
}, },
]; ];
} }
function LevelPreview({ levels }) { function CPUserCell({ user }) {
if (!user?.userId && !user?.displayUserId && !user?.username) {
return "-";
}
return <AdminUserIdentity openInAppUserDetail rows={[user.displayUserId, user.userId]} user={user} />;
}
function GiftCell({ gift }) {
if (!gift?.giftId && !gift?.giftName && !gift?.giftValue) {
return "-";
}
const name = gift.giftName || gift.giftId || "-";
const meta = [gift.giftCount ? `x${formatNumber(gift.giftCount)}` : "", gift.giftId].filter(Boolean).join(" · ");
return ( return (
<div className={styles.levelPreview}> <div className={styles.giftCell}>
{levels.map((level) => ( {gift.giftIconUrl ? <img alt="" className={styles.giftIcon} src={gift.giftIconUrl} /> : null}
<span className={styles.levelPill} key={level.level}> <div className={styles.stack}>
{level.level} {formatNumber(level.intimacyThreshold)} <span className={styles.name}>{name}</span>
</span> <span className={styles.meta}>{meta || "礼物"}</span>
))} <span className={styles.amount}>{formatNumber(gift.giftValue)} 价值</span>
</div>
</div> </div>
); );
} }
function StatusBadge({ status }) { function RelationTypeBadge({ relationType }) {
const active = status === "active";
return ( return (
<span className={[styles.statusBadge, active ? styles.statusActive : styles.statusDisabled].join(" ")}> <span className={[styles.relationBadge, styles[`relationBadge_${relationType}`]].filter(Boolean).join(" ")}>
{active ? "启用" : "停用"} {cpRelationLabels[relationType] || relationType || "-"}
</span> </span>
); );
} }
function StatusBadge({ status }) {
const normalized = String(status || "").toLowerCase();
return (
<span className={[styles.statusBadge, statusClassName(normalized)].filter(Boolean).join(" ")}>
{statusLabel(normalized)}
</span>
);
}
function NullableTime({ value }) {
return value ? <TimeText value={value} /> : "-";
}
function SummaryItem({ children, label }) { function SummaryItem({ children, label }) {
return ( return (
<div className={styles.summaryItem}> <div className={styles.summaryItem}>
@ -408,6 +534,45 @@ function resourceGroupLabel(group, value) {
return value ? `资源组 #${value}` : "未配置奖励"; return value ? `资源组 #${value}` : "未配置奖励";
} }
function formatRelationDays(item, serverTimeMs) {
if (item.durationDays) {
return `${formatNumber(item.durationDays)}`;
}
if (!item.formedAtMs) {
return "-";
}
const endMs = item.endedAtMs || serverTimeMs || Date.now();
const days = Math.max(1, Math.floor((Number(endMs) - Number(item.formedAtMs)) / 86400000) + 1);
return `${formatNumber(days)}`;
}
function statusLabel(status) {
const labels = {
accepted: "已同意",
active: "启用",
blocked: "已阻断",
disabled: "停用",
ended: "已解除",
expired: "已过期",
pending: "待处理",
rejected: "已拒绝",
};
return labels[status] || status || "-";
}
function statusClassName(status) {
if (status === "active" || status === "accepted") {
return styles.statusActive;
}
if (status === "pending") {
return styles.statusPending;
}
if (status === "rejected" || status === "expired") {
return styles.statusDanger;
}
return styles.statusDisabled;
}
function formatNumber(value) { function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN"); return Number(value || 0).toLocaleString("zh-CN");
} }

View File

@ -1,8 +1,15 @@
import { stageOptions } from "@/features/lucky-gift/constants.js"; import { stageOptions } from "@/features/lucky-gift/constants.js";
import {
applyProbabilityMap,
correctRTPProbabilities,
decimal,
expectedRTPPercent,
formatDecimal,
probabilityTotal,
tierRTPContributionPercent,
} from "@/shared/utils/rtpProbability.js";
export const luckyGiftReferenceCost = 100; export const luckyGiftReferenceCost = 100;
export const minimumCorrectedProbabilityPercent = 0.01;
const ppmScale = 1_000_000;
const defaultStageTiers = { const defaultStageTiers = {
novice: [ novice: [
@ -170,42 +177,20 @@ export function updateStageTier(stages, stageKey, tierIndex, patch) {
} }
export function correctStageTierProbabilities(stages, stageKey, targetRTPPercent) { export function correctStageTierProbabilities(stages, stageKey, targetRTPPercent) {
const targetRTP = decimal(targetRTPPercent);
return stages.map((stage) => { return stages.map((stage) => {
if (stage.stage !== stageKey) { if (stage.stage !== stageKey) {
return stage; return stage;
} }
const enabledTiers = stage.tiers const probabilityByIndex = correctRTPProbabilities(stage.tiers, targetRTPPercent, {
.map((item, index) => ({ index, multiplier: decimal(item.multiplier), tier: item })) multiplier: (item) => item.multiplier,
.filter((item) => item.tier.enabled !== false); participatesInRTP: (item) => item.rewardSource === "base_rtp",
const baseTiers = enabledTiers.filter( });
(item) => item.tier.rewardSource === "base_rtp" && Number.isFinite(item.multiplier), if (!probabilityByIndex) {
);
const positiveTiers = baseTiers
.filter((item) => item.multiplier > 0)
.sort((left, right) => left.multiplier - right.multiplier);
const zeroTiers = baseTiers.filter((item) => item.multiplier === 0);
if (enabledTiers.length === 0 || positiveTiers.length === 0) {
return stage; return stage;
} }
const probabilityByIndex = positiveBaselineProbabilities(enabledTiers);
const fixedProbability = sumProbabilities(probabilityByIndex);
if (fixedProbability >= 100) {
return { ...stage, tiers: applyCorrectionProbabilities(stage.tiers, probabilityByIndex) };
}
const fixedExpectedRTP = positiveTiers.reduce(
(sum, item) => sum + item.multiplier * (probabilityByIndex.get(item.index) || 0),
0,
);
const remainingProbability = 100 - fixedProbability;
const targetExtraRTP = targetRTP - fixedExpectedRTP;
const contributionPlan = balancedRTPContributionPlan(positiveTiers, targetExtraRTP, remainingProbability);
addContributionProbabilities(probabilityByIndex, contributionPlan);
assignResidualProbability(probabilityByIndex, zeroTiers, positiveTiers);
alignExpectedRTPByWeightPPM(probabilityByIndex, baseTiers, targetRTP);
return { return {
...stage, ...stage,
tiers: applyCorrectionProbabilities(stage.tiers, probabilityByIndex), tiers: applyProbabilityMap(stage.tiers, probabilityByIndex),
}; };
}); });
} }
@ -213,29 +198,22 @@ export function correctStageTierProbabilities(stages, stageKey, targetRTPPercent
export function stageProbabilityTotal(stage) { export function stageProbabilityTotal(stage) {
// 与 activity-service 的发布校验保持同一口径:只有启用奖档参与 100% 概率闭合; // 与 activity-service 的发布校验保持同一口径:只有启用奖档参与 100% 概率闭合;
// 禁用奖档只是草稿行,不能让前端显示通过但服务端按启用集合拒绝。 // 禁用奖档只是草稿行,不能让前端显示通过但服务端按启用集合拒绝。
return (stage?.tiers || []).reduce((sum, item) => { return probabilityTotal(stage?.tiers || []);
if (item?.enabled === false) {
return sum;
}
return sum + decimal(item.probabilityPercent);
}, 0);
} }
export function stageExpectedRTP(stage) { export function stageExpectedRTP(stage) {
// 阶段期望 RTP 是发布时真正校验的概率口径:只统计启用的基础 RTP 奖档,活动补贴和表现奖励不参与基础返奖率。 // 阶段期望 RTP 是发布时真正校验的概率口径:只统计启用的基础 RTP 奖档,活动补贴和表现奖励不参与基础返奖率。
return (stage?.tiers || []).reduce((sum, item) => { return expectedRTPPercent(stage?.tiers || [], {
if (item?.enabled === false || item?.rewardSource !== "base_rtp") { multiplier: (item) => item.multiplier,
return sum; participatesInRTP: (item) => item.rewardSource === "base_rtp",
} });
return sum + expectedContributionPercent(item.multiplier, item.probabilityPercent);
}, 0);
} }
export function tierRTPContribution(tier) { export function tierRTPContribution(tier) {
if (tier?.enabled === false || tier?.rewardSource !== "base_rtp") { return tierRTPContributionPercent(tier, {
return 0; multiplier: (item) => item.multiplier,
} participatesInRTP: (item) => item.rewardSource === "base_rtp",
return expectedContributionPercent(tier.multiplier, tier.probabilityPercent); });
} }
export function assignGeneratedTierIDs(stages) { export function assignGeneratedTierIDs(stages) {
@ -313,229 +291,6 @@ function cloneStages(stages) {
return stages.map((stage) => ({ ...stage, tiers: stage.tiers.map((item) => ({ ...item })) })); return stages.map((stage) => ({ ...stage, tiers: stage.tiers.map((item) => ({ ...item })) }));
} }
function positiveBaselineProbabilities(enabled) {
const probabilities = new Map();
const floor =
enabled.length * minimumCorrectedProbabilityPercent <= 100
? minimumCorrectedProbabilityPercent
: 100 / enabled.length;
for (const item of enabled) {
probabilities.set(item.index, roundPercent(floor));
}
return probabilities;
}
function balancedRTPContributionPlan(positiveTiers, targetExtraRTP, remainingProbability) {
if (targetExtraRTP <= 0) {
return [];
}
const maxMultiplier = positiveTiers[positiveTiers.length - 1].multiplier;
if (targetExtraRTP >= remainingProbability * maxMultiplier) {
// 目标 RTP 超过当前倍率表可达上限时,所有剩余概率只能压到最大倍率;保存校验会继续提示无法达标。
return [{ tier: positiveTiers[positiveTiers.length - 1], probability: remainingProbability }];
}
const alpha = minimumFeasibleContributionAlpha(positiveTiers, targetExtraRTP, remainingProbability);
const weighted = contributionWeights(positiveTiers, alpha);
return positiveTiers.map((tier, index) => ({
tier,
probability: roundPercent((targetExtraRTP * weighted[index]) / tier.multiplier),
}));
}
function minimumFeasibleContributionAlpha(positiveTiers, targetExtraRTP, remainingProbability) {
// alpha=0 代表所有正倍率平均分 RTP 贡献;如果低倍率太多导致概率总和超过 100%,逐步提高 alpha
// 让更高倍率承担更多 RTP 贡献。这样 10x/100x 不会被挤成 0又能保持整体期望返奖率。
if (contributionProbabilityTotal(positiveTiers, targetExtraRTP, 0) <= remainingProbability) {
return 0;
}
let low = 0;
let high = 1;
while (contributionProbabilityTotal(positiveTiers, targetExtraRTP, high) > remainingProbability && high < 64) {
high *= 2;
}
for (let step = 0; step < 48; step += 1) {
const mid = (low + high) / 2;
if (contributionProbabilityTotal(positiveTiers, targetExtraRTP, mid) > remainingProbability) {
low = mid;
} else {
high = mid;
}
}
return high;
}
function contributionProbabilityTotal(positiveTiers, targetExtraRTP, alpha) {
const weights = contributionWeights(positiveTiers, alpha);
return positiveTiers.reduce((sum, tier, index) => sum + (targetExtraRTP * weights[index]) / tier.multiplier, 0);
}
function contributionWeights(positiveTiers, alpha) {
const rawWeights = positiveTiers.map((tier) => Math.max(0, tier.multiplier) ** alpha);
const total = rawWeights.reduce((sum, value) => sum + value, 0);
if (total <= 0) {
return positiveTiers.map(() => 1 / positiveTiers.length);
}
return rawWeights.map((value) => value / total);
}
function addContributionProbabilities(probabilities, contributionPlan) {
for (const item of contributionPlan) {
probabilities.set(item.tier.index, roundPercent((probabilities.get(item.tier.index) || 0) + item.probability));
}
}
function assignResidualProbability(probabilities, zeroTiers, positiveTiers) {
const residual = roundPercent(100 - sumProbabilities(probabilities));
if (residual <= 0) {
normalizeProbabilityTotal(probabilities, zeroTiers[0] || positiveTiers[0]);
return;
}
const receiver = zeroTiers[0] || positiveTiers[0];
if (!receiver) {
return;
}
probabilities.set(receiver.index, roundPercent((probabilities.get(receiver.index) || 0) + residual));
normalizeProbabilityTotal(probabilities, receiver);
}
function normalizeProbabilityTotal(probabilities, receiver) {
if (!receiver) {
return;
}
const diff = roundPercent(100 - sumProbabilities(probabilities));
if (diff !== 0) {
probabilities.set(receiver.index, roundPercent((probabilities.get(receiver.index) || 0) + diff));
}
}
function alignExpectedRTPByWeightPPM(probabilities, baseTiers, targetRTP) {
const targetRTPPPM = percentToWeightPPM(targetRTP);
const weights = new Map();
for (const [index, probability] of probabilities.entries()) {
weights.set(index, percentToWeightPPM(probability));
}
normalizeWeightTotal(weights, baseTiers[0]);
let currentRTPPPM = expectedRTPPPMByWeights(weights, baseTiers);
const floorWeight = percentToWeightPPM(minimumCorrectedProbabilityPercent);
for (let step = 0; step < 20_000 && currentRTPPPM !== targetRTPPPM; step += 1) {
const diff = targetRTPPPM - currentRTPPPM;
const move = bestExpectedRTPMove(weights, baseTiers, diff, floorWeight);
if (!move || Math.abs(diff - move.delta) >= Math.abs(diff)) {
break;
}
weights.set(move.donor.index, (weights.get(move.donor.index) || 0) - 1);
weights.set(move.receiver.index, (weights.get(move.receiver.index) || 0) + 1);
currentRTPPPM += move.delta;
}
for (const [index, weight] of weights.entries()) {
probabilities.set(index, roundPercent(weight / 10_000));
}
}
function normalizeWeightTotal(weights, receiver) {
if (!receiver) {
return;
}
let total = 0;
for (const weight of weights.values()) {
total += weight;
}
const diff = ppmScale - total;
if (diff !== 0) {
weights.set(receiver.index, (weights.get(receiver.index) || 0) + diff);
}
}
function bestExpectedRTPMove(weights, baseTiers, diff, floorWeight) {
let best = null;
for (const donor of baseTiers) {
const donorWeight = weights.get(donor.index) || 0;
if (donorWeight <= floorWeight) {
continue;
}
for (const receiver of baseTiers) {
if (receiver.index === donor.index) {
continue;
}
const delta = expectedMoveDeltaPPM(weights, donor, receiver);
if ((diff > 0 && delta <= 0) || (diff < 0 && delta >= 0)) {
continue;
}
if (!best || betterMove(delta, best.delta, diff)) {
best = { delta, donor, receiver };
}
}
}
return best;
}
function betterMove(candidateDelta, bestDelta, diff) {
const candidateAbs = Math.abs(candidateDelta);
const bestAbs = Math.abs(bestDelta);
const diffAbs = Math.abs(diff);
const candidateUnder = candidateAbs <= diffAbs;
const bestUnder = bestAbs <= diffAbs;
if (candidateUnder !== bestUnder) {
return candidateUnder;
}
return candidateUnder ? candidateAbs > bestAbs : candidateAbs < bestAbs;
}
function expectedMoveDeltaPPM(weights, donor, receiver) {
const donorWeight = weights.get(donor.index) || 0;
const receiverWeight = weights.get(receiver.index) || 0;
return (
contributionPPM(receiver.multiplier, receiverWeight + 1) -
contributionPPM(receiver.multiplier, receiverWeight) +
contributionPPM(donor.multiplier, donorWeight - 1) -
contributionPPM(donor.multiplier, donorWeight)
);
}
function expectedRTPPPMByWeights(weights, baseTiers) {
return baseTiers.reduce((sum, tier) => sum + contributionPPM(tier.multiplier, weights.get(tier.index) || 0), 0);
}
function applyCorrectionProbabilities(tiers, probabilityByIndex) {
return tiers.map((item, index) => ({
...item,
// 启用奖档保留最小正概率;如果运营不想让某个倍率出现,应关闭该奖档,而不是让概率为 0。
probabilityPercent: formatDecimal(probabilityByIndex.get(index) || 0),
}));
}
function sumProbabilities(probabilities) {
let total = 0;
for (const value of probabilities.values()) {
total += value;
}
return total;
}
function roundPercent(value) {
// 后端会把 percent * 10000 转成 ppm 并要求每阶段启用奖档 ppm 精确合计 1,000,000
// 前端纠正结果也落到 0.0001% 粒度,避免 100.000000 这种浮点显示通过但 RPC 校验失败。
return Math.round(value * 10_000) / 10_000;
}
function expectedContributionPercent(multiplier, probabilityPercent) {
return contributionPPM(decimal(multiplier), percentToWeightPPM(probabilityPercent)) / 10_000;
}
function contributionPPM(multiplier, weightPPM) {
return Math.trunc((multiplierToPPM(multiplier) * weightPPM) / ppmScale);
}
function percentToWeightPPM(value) {
return Math.round(decimal(value) * 10_000);
}
function multiplierToPPM(value) {
return Math.round(decimal(value) * ppmScale);
}
function tier(tierId, multiplier, probabilityPercent, options = {}) { function tier(tierId, multiplier, probabilityPercent, options = {}) {
return { return {
broadcastLevel: options.broadcastLevel || "none", broadcastLevel: options.broadcastLevel || "none",
@ -548,19 +303,6 @@ function tier(tierId, multiplier, probabilityPercent, options = {}) {
}; };
} }
function formatDecimal(value) {
const number = Number(value || 0);
if (!Number.isFinite(number)) {
return "0";
}
return Number.isInteger(number) ? String(number) : number.toFixed(6).replace(/\.?0+$/, "");
}
function decimal(value) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}
function integer(value) { function integer(value) {
const number = Number(value || 0); const number = Number(value || 0);
return Number.isFinite(number) ? Math.round(number) : 0; return Number.isFinite(number) ? Math.round(number) : 0;

View File

@ -5,12 +5,9 @@ import { wheelConfigFormSchema } from "@/features/operations/schema";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { applyProbabilityMap, correctRTPProbabilities, decimal } from "@/shared/utils/rtpProbability.js";
const pageSize = 50; const pageSize = 50;
const probabilityScale = 1_000_000;
const probabilityPercentScale = 10_000;
const minimumTierProbabilityPPM = 100;
const randomRewardTypes = new Set(["prop", "dress"]);
const probabilityAffectingFields = new Set([ const probabilityAffectingFields = new Set([
"drawPriceCoins", "drawPriceCoins",
"targetRTPPercent", "targetRTPPercent",
@ -82,7 +79,10 @@ export function useWheelPage() {
queryKey: ["wheel-draws", summaryFilters, page], queryKey: ["wheel-draws", summaryFilters, page],
}); });
const resourceGroupQueryFn = useCallback(() => listResourceGroups({ page: 1, page_size: 300, status: "active" }), []); const resourceGroupQueryFn = useCallback(
() => listResourceGroups({ page: 1, page_size: 300, status: "active" }),
[],
);
const resourceGroupQuery = useAdminQuery(resourceGroupQueryFn, { const resourceGroupQuery = useAdminQuery(resourceGroupQueryFn, {
errorMessage: "获取资源组失败", errorMessage: "获取资源组失败",
initialData: { items: [] }, initialData: { items: [] },
@ -175,14 +175,23 @@ export function useWheelPage() {
return; return;
} }
setForm((current) => setForm((current) =>
withGeneratedTierProbabilities({ ...current, tiers: items.map((item, index) => groupItemToTier(item, wheelId, index)) }), withGeneratedTierProbabilities({
...current,
tiers: items.map((item, index) => groupItemToTier(item, wheelId, index)),
}),
); );
showToast("资源组已导入,可拖动排序", "success"); showToast("资源组已导入,可拖动排序", "success");
}; };
const moveTier = (fromIndex, toIndex) => { const moveTier = (fromIndex, toIndex) => {
setForm((current) => { setForm((current) => {
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= current.tiers.length || toIndex >= current.tiers.length) { if (
fromIndex === toIndex ||
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= current.tiers.length ||
toIndex >= current.tiers.length
) {
return current; return current;
} }
const tiers = [...current.tiers]; const tiers = [...current.tiers];
@ -275,20 +284,18 @@ function configToForm(config) {
} }
function normalizeTiers(tiers, wheelId) { function normalizeTiers(tiers, wheelId) {
return (tiers || []).map( return (tiers || []).map((tier, index) => ({
(tier, index) => ({ displayName: tier.displayName || "",
displayName: tier.displayName || "", enabled: true,
enabled: true, metadataJson: tier.metadataJson || "{}",
metadataJson: tier.metadataJson || "{}", probabilityPercent: tier.probabilityPercent || 0,
probabilityPercent: tier.probabilityPercent || 0, rewardCoins: tier.rewardCoins || 0,
rewardCoins: tier.rewardCoins || 0, rewardCount: tier.rewardCount || 0,
rewardCount: tier.rewardCount || 0, rewardId: tier.rewardId || "",
rewardId: tier.rewardId || "", rewardType: tier.rewardType || "coin",
rewardType: tier.rewardType || "coin", rtpValueCoins: tier.rewardType === "prop" || tier.rewardType === "dress" ? 0 : tier.rtpValueCoins || 0,
rtpValueCoins: tier.rewardType === "prop" || tier.rewardType === "dress" ? 0 : tier.rtpValueCoins || 0, tierId: tier.tierId || `${wheelId}-${index + 1}`,
tierId: tier.tierId || `${wheelId}-${index + 1}`, }));
}),
);
} }
function isGeneratedPlaceholderTiers(tiers, wheelId) { function isGeneratedPlaceholderTiers(tiers, wheelId) {
@ -363,7 +370,9 @@ function groupItemToTier(item, wheelId, index) {
resource_type: resource.resourceType || "", resource_type: resource.resourceType || "",
}; };
return { return {
displayName: isCoin ? "金币" : resource.name || resource.resourceCode || `资源 ${resource.resourceId || index + 1}`, displayName: isCoin
? "金币"
: resource.name || resource.resourceCode || `资源 ${resource.resourceId || index + 1}`,
enabled: true, enabled: true,
metadataJson: JSON.stringify(metadata), metadataJson: JSON.stringify(metadata),
probabilityPercent: 0, probabilityPercent: 0,
@ -385,144 +394,36 @@ export function generateWheelTierProbabilities(form) {
if (!tiers.length) { if (!tiers.length) {
return tiers; return tiers;
} }
// 转盘没有幸运礼物的倍率字段,这里把每个奖档的 RTP 价值换算成“相对单抽价格的倍率”。
const ppm = tiers.map(() => minimumTierProbabilityPPM); // 道具/装扮不计入 RTP因此只拿公共公式保留的最小正概率全无 RTP 奖档时退化为均分,保证仍可发布配置。
const randomIndexes = []; const probabilityByIndex = correctRTPProbabilities(tiers, form.targetRTPPercent, {
const rtpIndexes = []; fallback: "equal",
const rtpValues = new Map(); multiplier: (tier) => wheelTierMultiplier(tier, form.drawPriceCoins),
participatesInRTP: wheelTierParticipatesInRTP,
tiers.forEach((tier, index) => {
const rewardType = tier.rewardType;
if (randomRewardTypes.has(rewardType)) {
randomIndexes.push(index);
return;
}
const rtpValue = rtpTierValue(tier);
if (rtpValue > 0) {
rtpIndexes.push(index);
rtpValues.set(index, rtpValue);
} else {
randomIndexes.push(index);
}
}); });
return applyProbabilityMap(tiers, probabilityByIndex, (value) => Number(value.toFixed(4)));
const availablePPM = Math.max(0, probabilityScale - minimumTierProbabilityPPM * tiers.length);
if (!rtpIndexes.length) {
distributeEqual(ppm, randomIndexes.length ? randomIndexes : tiers.map((_, index) => index), availablePPM);
return formatTierProbabilities(tiers, ppm);
}
const targetExpectedCoins = (numberValue(form.drawPriceCoins) * numberValue(form.targetRTPPercent)) / 100;
const baselineExpectedCoins = rtpIndexes.reduce(
(sum, index) => sum + (minimumTierProbabilityPPM / probabilityScale) * (rtpValues.get(index) || 0),
0,
);
const remainingTargetCoins = Math.max(0, targetExpectedCoins - baselineExpectedCoins);
const sortedRtpIndexes = [...rtpIndexes].sort((left, right) => (rtpValues.get(left) || 0) - (rtpValues.get(right) || 0));
const minRtpValue = rtpValues.get(sortedRtpIndexes[0]) || 0;
const maxRtpValue = rtpValues.get(sortedRtpIndexes[sortedRtpIndexes.length - 1]) || minRtpValue;
// 非 RTP 奖励没有金币期望值,默认只拿一个小的随机池;如果目标 RTP 需要更多金币/礼物概率,
// 这里会自动压缩随机池,保证提交前概率合计和期望 RTP 都尽量贴近基础配置。
const desiredRandomPPM = randomIndexes.length ? Math.min(300_000, randomIndexes.length * 20_000) : 0;
const minRtpExtraPPM = maxRtpValue > 0 ? Math.ceil((remainingTargetCoins * probabilityScale) / maxRtpValue) : availablePPM;
const maxRtpExtraPPM = minRtpValue > 0 ? Math.floor((remainingTargetCoins * probabilityScale) / minRtpValue) : availablePPM;
const desiredRtpExtraPPM = randomIndexes.length ? availablePPM - desiredRandomPPM : availablePPM;
const rtpExtraPPM = randomIndexes.length ? clamp(desiredRtpExtraPPM, 0, availablePPM, minRtpExtraPPM, maxRtpExtraPPM) : availablePPM;
const randomExtraPPM = availablePPM - rtpExtraPPM;
distributeEqual(ppm, randomIndexes, randomExtraPPM);
distributeRtpProbability(ppm, sortedRtpIndexes, rtpValues, rtpExtraPPM, remainingTargetCoins);
normalizeProbabilityTotal(ppm);
return formatTierProbabilities(tiers, ppm);
} }
function distributeRtpProbability(ppm, sortedIndexes, rtpValues, availablePPM, targetCoins) { function wheelTierMultiplier(tier, drawPriceCoins) {
if (availablePPM <= 0 || !sortedIndexes.length) { const drawPrice = decimal(drawPriceCoins);
return; if (drawPrice <= 0) {
return 0;
} }
if (sortedIndexes.length === 1) { return wheelTierRTPValueCoins(tier) / drawPrice;
ppm[sortedIndexes[0]] += availablePPM;
return;
}
const averageTarget = targetCoins / (availablePPM / probabilityScale);
let lowerIndex = sortedIndexes[0];
let upperIndex = sortedIndexes[sortedIndexes.length - 1];
for (let index = 0; index < sortedIndexes.length - 1; index += 1) {
const currentIndex = sortedIndexes[index];
const nextIndex = sortedIndexes[index + 1];
const currentValue = rtpValues.get(currentIndex) || 0;
const nextValue = rtpValues.get(nextIndex) || currentValue;
if (averageTarget >= currentValue && averageTarget <= nextValue) {
lowerIndex = currentIndex;
upperIndex = nextIndex;
break;
}
}
const lowerValue = rtpValues.get(lowerIndex) || 0;
const upperValue = rtpValues.get(upperIndex) || lowerValue;
if (upperValue <= lowerValue) {
ppm[lowerIndex] += availablePPM;
return;
}
// 在两个相邻 RTP 价值档之间做线性分配,可以同时满足“概率合计 100”和“期望返奖贴近目标 RTP”。
const upperRatio = Math.max(0, Math.min(1, (averageTarget - lowerValue) / (upperValue - lowerValue)));
const upperPPM = Math.round(availablePPM * upperRatio);
ppm[upperIndex] += upperPPM;
ppm[lowerIndex] += availablePPM - upperPPM;
} }
function distributeEqual(ppm, indexes, totalPPM) { function wheelTierRTPValueCoins(tier) {
if (!indexes.length || totalPPM <= 0) {
return;
}
const base = Math.floor(totalPPM / indexes.length);
let remainder = totalPPM - base * indexes.length;
indexes.forEach((index) => {
ppm[index] += base + (remainder > 0 ? 1 : 0);
remainder -= 1;
});
}
function normalizeProbabilityTotal(ppm) {
const delta = probabilityScale - ppm.reduce((sum, value) => sum + value, 0);
if (delta !== 0 && ppm.length) {
ppm[ppm.length - 1] += delta;
}
}
function formatTierProbabilities(tiers, ppm) {
return tiers.map((tier, index) => ({
...tier,
probabilityPercent: Number((ppm[index] / probabilityPercentScale).toFixed(4)),
}));
}
function rtpTierValue(tier) {
if (tier.rewardType === "coin") { if (tier.rewardType === "coin") {
return numberValue(tier.rewardCoins || tier.rtpValueCoins); return decimal(tier.rewardCoins || tier.rtpValueCoins);
} }
if (tier.rewardType === "gift") { if (tier.rewardType === "gift") {
return numberValue(tier.rtpValueCoins); return decimal(tier.rtpValueCoins);
} }
return 0; return 0;
} }
function numberValue(value) { function wheelTierParticipatesInRTP(tier) {
const number = Number(value); return tier.rewardType === "coin" || tier.rewardType === "gift";
return Number.isFinite(number) ? number : 0;
}
function clamp(value, min, max, feasibleMin, feasibleMax) {
const lower = Math.max(min, Number.isFinite(feasibleMin) ? feasibleMin : min);
const upper = Math.min(max, Number.isFinite(feasibleMax) ? feasibleMax : max);
if (lower > upper) {
return Math.max(min, Math.min(max, value < lower ? lower : upper));
}
return Math.max(lower, Math.min(upper, value));
} }
function resourceTypeToRewardType(resourceType) { function resourceTypeToRewardType(resourceType) {

View File

@ -22,13 +22,13 @@ function probabilityPPM(tiers) {
return tiers.reduce((sum, item) => sum + Math.round(item.probabilityPercent * 10_000), 0); return tiers.reduce((sum, item) => sum + Math.round(item.probabilityPercent * 10_000), 0);
} }
function expectedPayoutCoins(tiers) { function wheelExpectedRTP(tiers, drawPriceCoins) {
return tiers.reduce((sum, item) => { return tiers.reduce((sum, item) => {
if (item.rewardType === "coin") { if (item.rewardType === "coin") {
return sum + (item.probabilityPercent / 100) * Number(item.rewardCoins || 0); return sum + (Number(item.rewardCoins || 0) / drawPriceCoins) * Number(item.probabilityPercent || 0);
} }
if (item.rewardType === "gift") { if (item.rewardType === "gift") {
return sum + (item.probabilityPercent / 100) * Number(item.rtpValueCoins || 0); return sum + (Number(item.rtpValueCoins || 0) / drawPriceCoins) * Number(item.probabilityPercent || 0);
} }
return sum; return sum;
}, 0); }, 0);
@ -78,14 +78,23 @@ describe("wheel prize probability generation", () => {
tiers: generateWheelTierProbabilities({ tiers: generateWheelTierProbabilities({
drawPriceCoins: 100, drawPriceCoins: 100,
targetRTPPercent: 90, targetRTPPercent: 90,
tiers: [...tiers, tier({ rewardCoins: 100, rewardId: "", rewardType: "coin", rtpValueCoins: 100, tierId: "classic-9" })], tiers: [
...tiers,
tier({
rewardCoins: 100,
rewardId: "",
rewardType: "coin",
rtpValueCoins: 100,
tierId: "classic-9",
}),
],
}), }),
wheelId: "classic", wheelId: "classic",
}).success, }).success,
).toBe(false); ).toBe(false);
}); });
test("uses the base RTP to generate coin and gift probabilities while keeping dress and prop random", () => { test("uses the shared RTP formula to correct reachable wheel probabilities", () => {
const result = generateWheelTierProbabilities({ const result = generateWheelTierProbabilities({
drawPriceCoins: 10000, drawPriceCoins: 10000,
targetRTPPercent: 90, targetRTPPercent: 90,
@ -98,12 +107,31 @@ describe("wheel prize probability generation", () => {
}); });
expect(probabilityPPM(result)).toBe(1_000_000); expect(probabilityPPM(result)).toBe(1_000_000);
expect(expectedPayoutCoins(result)).toBeCloseTo(9000, 0); expect(wheelExpectedRTP(result, 10000)).toBeCloseTo(90, 4);
expect(result[2].probabilityPercent).toBe(result[3].probabilityPercent); expect(result[2].probabilityPercent).toBe(0.01);
expect(result[0].probabilityPercent + result[1].probabilityPercent).toBeGreaterThan(95); expect(result[3].probabilityPercent).toBe(0.01);
}); });
test("falls back to pure random probabilities when a prize set has no RTP value", () => { test("clamps to the closest reachable RTP when every reward is above the target multiplier", () => {
const result = generateWheelTierProbabilities({
drawPriceCoins: 10000,
targetRTPPercent: 90,
tiers: Array.from({ length: 12 }, (_, index) =>
tier({
rewardId: String(index + 1),
rewardType: "gift",
rtpValueCoins: 50000 * (index + 1),
tierId: `advanced-${index + 1}`,
}),
),
});
expect(probabilityPPM(result)).toBe(1_000_000);
expect(wheelExpectedRTP(result, 10000)).toBeGreaterThan(90);
expect(result.every((item) => Number(item.probabilityPercent) > 0)).toBe(true);
});
test("falls back to equal probabilities when the wheel has no RTP prize", () => {
const result = generateWheelTierProbabilities({ const result = generateWheelTierProbabilities({
drawPriceCoins: 10000, drawPriceCoins: 10000,
targetRTPPercent: 90, targetRTPPercent: 90,
@ -114,7 +142,6 @@ describe("wheel prize probability generation", () => {
}); });
expect(probabilityPPM(result)).toBe(1_000_000); expect(probabilityPPM(result)).toBe(1_000_000);
expect(result[0].probabilityPercent).toBe(50); expect(result.map((item) => item.probabilityPercent)).toEqual([50, 50]);
expect(result[1].probabilityPercent).toBe(50);
}); });
}); });

View File

@ -145,6 +145,8 @@ export const API_OPERATIONS = {
listCoinSellerLedger: "listCoinSellerLedger", listCoinSellerLedger: "listCoinSellerLedger",
listCoinSellers: "listCoinSellers", listCoinSellers: "listCoinSellers",
listCountries: "listCountries", listCountries: "listCountries",
listCPApplications: "listCPApplications",
listCPRelationships: "listCPRelationships",
listCumulativeRechargeRewardGrants: "listCumulativeRechargeRewardGrants", listCumulativeRechargeRewardGrants: "listCumulativeRechargeRewardGrants",
listDiceRobots: "listDiceRobots", listDiceRobots: "listDiceRobots",
listEmojiPackCategories: "listEmojiPackCategories", listEmojiPackCategories: "listEmojiPackCategories",
@ -1211,6 +1213,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "country:view", permission: "country:view",
permissions: ["country:view"] permissions: ["country:view"]
}, },
listCPApplications: {
method: "GET",
operationId: API_OPERATIONS.listCPApplications,
path: "/v1/admin/activity/cp/applications",
permission: "cp-config:view",
permissions: ["cp-config:view"]
},
listCPRelationships: {
method: "GET",
operationId: API_OPERATIONS.listCPRelationships,
path: "/v1/admin/activity/cp/relationships",
permission: "cp-config:view",
permissions: ["cp-config:view"]
},
listCumulativeRechargeRewardGrants: { listCumulativeRechargeRewardGrants: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listCumulativeRechargeRewardGrants, operationId: API_OPERATIONS.listCumulativeRechargeRewardGrants,

View File

@ -116,6 +116,38 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/admin/activity/cp/relationships": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listCPRelationships"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/cp/applications": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listCPApplications"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/red-packets": { "/admin/activity/red-packets": {
parameters: { parameters: {
query?: never; query?: never;
@ -3733,6 +3765,30 @@ export interface operations {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["EmptyResponse"];
}; };
}; };
listCPRelationships: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listCPApplications: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listRedPackets: { listRedPackets: {
parameters: { parameters: {
query?: never; query?: never;

View File

@ -0,0 +1,324 @@
export const minimumCorrectedProbabilityPercent = 0.01;
const ppmScale = 1_000_000;
export function correctRTPProbabilities(tiers, targetRTPPercent, options = {}) {
const enabled = options.enabled || ((item) => item?.enabled !== false);
const multiplier = options.multiplier || ((item) => item?.multiplier);
const participatesInRTP = options.participatesInRTP || (() => true);
const fallback = options.fallback || "unchanged";
const targetRTP = decimal(targetRTPPercent);
const enabledTiers = (tiers || [])
.map((item, index) => ({ index, multiplier: decimal(multiplier(item, index)), tier: item }))
.filter((item) => enabled(item.tier, item.index));
const baseTiers = enabledTiers.filter(
(item) => participatesInRTP(item.tier, item.index) && Number.isFinite(item.multiplier),
);
const positiveTiers = baseTiers
.filter((item) => item.multiplier > 0)
.sort((left, right) => left.multiplier - right.multiplier);
const zeroTiers = baseTiers.filter((item) => item.multiplier === 0);
if (enabledTiers.length === 0) {
return new Map();
}
if (positiveTiers.length === 0) {
return fallback === "equal" ? equalProbabilities(enabledTiers) : null;
}
const probabilityByIndex = positiveBaselineProbabilities(enabledTiers);
const fixedProbability = sumProbabilities(probabilityByIndex);
if (fixedProbability >= 100) {
return probabilityByIndex;
}
const fixedExpectedRTP = positiveTiers.reduce(
(sum, item) => sum + item.multiplier * (probabilityByIndex.get(item.index) || 0),
0,
);
const remainingProbability = 100 - fixedProbability;
const targetExtraRTP = targetRTP - fixedExpectedRTP;
const contributionPlan = balancedRTPContributionPlan(positiveTiers, targetExtraRTP, remainingProbability);
addContributionProbabilities(probabilityByIndex, contributionPlan);
assignResidualProbability(probabilityByIndex, zeroTiers, positiveTiers);
alignExpectedRTPByWeightPPM(probabilityByIndex, baseTiers, targetRTP);
return probabilityByIndex;
}
export function applyProbabilityMap(tiers, probabilityByIndex, format = formatDecimal) {
if (!probabilityByIndex) {
return tiers;
}
return (tiers || []).map((item, index) => ({
...item,
probabilityPercent: format(probabilityByIndex.get(index) || 0),
}));
}
export function probabilityTotal(tiers, options = {}) {
const enabled = options.enabled || ((item) => item?.enabled !== false);
return (tiers || []).reduce((sum, item, index) => {
if (!enabled(item, index)) {
return sum;
}
return sum + decimal(options.probability ? options.probability(item, index) : item?.probabilityPercent);
}, 0);
}
export function expectedRTPPercent(tiers, options = {}) {
const enabled = options.enabled || ((item) => item?.enabled !== false);
const multiplier = options.multiplier || ((item) => item?.multiplier);
const participatesInRTP = options.participatesInRTP || (() => true);
return (tiers || []).reduce((sum, item, index) => {
if (!enabled(item, index) || !participatesInRTP(item, index)) {
return sum;
}
return sum + expectedContributionPercent(multiplier(item, index), item?.probabilityPercent);
}, 0);
}
export function tierRTPContributionPercent(tier, options = {}) {
const enabled = options.enabled || ((item) => item?.enabled !== false);
const multiplier = options.multiplier || ((item) => item?.multiplier);
const participatesInRTP = options.participatesInRTP || (() => true);
if (!enabled(tier, 0) || !participatesInRTP(tier, 0)) {
return 0;
}
return expectedContributionPercent(multiplier(tier, 0), tier?.probabilityPercent);
}
export function percentToWeightPPM(value) {
return Math.round(decimal(value) * 10_000);
}
export function formatDecimal(value) {
const number = Number(value || 0);
if (!Number.isFinite(number)) {
return "0";
}
return Number.isInteger(number) ? String(number) : number.toFixed(6).replace(/\.?0+$/, "");
}
export function decimal(value) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}
function equalProbabilities(enabledTiers) {
const probabilities = new Map();
const base = Math.floor(ppmScale / enabledTiers.length);
let remainder = ppmScale - base * enabledTiers.length;
for (const item of enabledTiers) {
const ppm = base + (remainder > 0 ? 1 : 0);
remainder -= 1;
probabilities.set(item.index, roundPercent(ppm / 10_000));
}
return probabilities;
}
function positiveBaselineProbabilities(enabledTiers) {
const probabilities = new Map();
const floor =
enabledTiers.length * minimumCorrectedProbabilityPercent <= 100
? minimumCorrectedProbabilityPercent
: 100 / enabledTiers.length;
for (const item of enabledTiers) {
probabilities.set(item.index, roundPercent(floor));
}
return probabilities;
}
function balancedRTPContributionPlan(positiveTiers, targetExtraRTP, remainingProbability) {
if (targetExtraRTP <= 0) {
return [];
}
const maxMultiplier = positiveTiers[positiveTiers.length - 1].multiplier;
if (targetExtraRTP >= remainingProbability * maxMultiplier) {
return [{ tier: positiveTiers[positiveTiers.length - 1], probability: remainingProbability }];
}
const alpha = minimumFeasibleContributionAlpha(positiveTiers, targetExtraRTP, remainingProbability);
const weighted = contributionWeights(positiveTiers, alpha);
return positiveTiers.map((tier, index) => ({
tier,
probability: roundPercent((targetExtraRTP * weighted[index]) / tier.multiplier),
}));
}
function minimumFeasibleContributionAlpha(positiveTiers, targetExtraRTP, remainingProbability) {
if (contributionProbabilityTotal(positiveTiers, targetExtraRTP, 0) <= remainingProbability) {
return 0;
}
let low = 0;
let high = 1;
while (contributionProbabilityTotal(positiveTiers, targetExtraRTP, high) > remainingProbability && high < 64) {
high *= 2;
}
for (let step = 0; step < 48; step += 1) {
const mid = (low + high) / 2;
if (contributionProbabilityTotal(positiveTiers, targetExtraRTP, mid) > remainingProbability) {
low = mid;
} else {
high = mid;
}
}
return high;
}
function contributionProbabilityTotal(positiveTiers, targetExtraRTP, alpha) {
const weights = contributionWeights(positiveTiers, alpha);
return positiveTiers.reduce((sum, tier, index) => sum + (targetExtraRTP * weights[index]) / tier.multiplier, 0);
}
function contributionWeights(positiveTiers, alpha) {
const rawWeights = positiveTiers.map((tier) => Math.max(0, tier.multiplier) ** alpha);
const total = rawWeights.reduce((sum, value) => sum + value, 0);
if (total <= 0) {
return positiveTiers.map(() => 1 / positiveTiers.length);
}
return rawWeights.map((value) => value / total);
}
function addContributionProbabilities(probabilities, contributionPlan) {
for (const item of contributionPlan) {
probabilities.set(item.tier.index, roundPercent((probabilities.get(item.tier.index) || 0) + item.probability));
}
}
function assignResidualProbability(probabilities, zeroTiers, positiveTiers) {
const residual = roundPercent(100 - sumProbabilities(probabilities));
if (residual <= 0) {
normalizeProbabilityTotal(probabilities, zeroTiers[0] || positiveTiers[0]);
return;
}
const receiver = zeroTiers[0] || positiveTiers[0];
if (!receiver) {
return;
}
probabilities.set(receiver.index, roundPercent((probabilities.get(receiver.index) || 0) + residual));
normalizeProbabilityTotal(probabilities, receiver);
}
function normalizeProbabilityTotal(probabilities, receiver) {
if (!receiver) {
return;
}
const diff = roundPercent(100 - sumProbabilities(probabilities));
if (diff !== 0) {
probabilities.set(receiver.index, roundPercent((probabilities.get(receiver.index) || 0) + diff));
}
}
function alignExpectedRTPByWeightPPM(probabilities, baseTiers, targetRTP) {
const targetRTPPPM = percentToWeightPPM(targetRTP);
const weights = new Map();
for (const [index, probability] of probabilities.entries()) {
weights.set(index, percentToWeightPPM(probability));
}
normalizeWeightTotal(weights, baseTiers[0]);
let currentRTPPPM = expectedRTPPPMByWeights(weights, baseTiers);
const floorWeight = percentToWeightPPM(minimumCorrectedProbabilityPercent);
for (let step = 0; step < 20_000 && currentRTPPPM !== targetRTPPPM; step += 1) {
const diff = targetRTPPPM - currentRTPPPM;
const move = bestExpectedRTPMove(weights, baseTiers, diff, floorWeight);
if (!move || Math.abs(diff - move.delta) >= Math.abs(diff)) {
break;
}
weights.set(move.donor.index, (weights.get(move.donor.index) || 0) - 1);
weights.set(move.receiver.index, (weights.get(move.receiver.index) || 0) + 1);
currentRTPPPM += move.delta;
}
for (const [index, weight] of weights.entries()) {
probabilities.set(index, roundPercent(weight / 10_000));
}
}
function normalizeWeightTotal(weights, receiver) {
if (!receiver) {
return;
}
let total = 0;
for (const weight of weights.values()) {
total += weight;
}
const diff = ppmScale - total;
if (diff !== 0) {
weights.set(receiver.index, (weights.get(receiver.index) || 0) + diff);
}
}
function bestExpectedRTPMove(weights, baseTiers, diff, floorWeight) {
let best = null;
for (const donor of baseTiers) {
const donorWeight = weights.get(donor.index) || 0;
if (donorWeight <= floorWeight) {
continue;
}
for (const receiver of baseTiers) {
if (receiver.index === donor.index) {
continue;
}
const delta = expectedMoveDeltaPPM(weights, donor, receiver);
if ((diff > 0 && delta <= 0) || (diff < 0 && delta >= 0)) {
continue;
}
if (!best || betterMove(delta, best.delta, diff)) {
best = { delta, donor, receiver };
}
}
}
return best;
}
function betterMove(candidateDelta, bestDelta, diff) {
const candidateAbs = Math.abs(candidateDelta);
const bestAbs = Math.abs(bestDelta);
const diffAbs = Math.abs(diff);
const candidateUnder = candidateAbs <= diffAbs;
const bestUnder = bestAbs <= diffAbs;
if (candidateUnder !== bestUnder) {
return candidateUnder;
}
return candidateUnder ? candidateAbs > bestAbs : candidateAbs < bestAbs;
}
function expectedMoveDeltaPPM(weights, donor, receiver) {
const donorWeight = weights.get(donor.index) || 0;
const receiverWeight = weights.get(receiver.index) || 0;
return (
contributionPPM(receiver.multiplier, receiverWeight + 1) -
contributionPPM(receiver.multiplier, receiverWeight) +
contributionPPM(donor.multiplier, donorWeight - 1) -
contributionPPM(donor.multiplier, donorWeight)
);
}
function expectedRTPPPMByWeights(weights, baseTiers) {
return baseTiers.reduce((sum, tier) => sum + contributionPPM(tier.multiplier, weights.get(tier.index) || 0), 0);
}
function sumProbabilities(probabilities) {
let total = 0;
for (const value of probabilities.values()) {
total += value;
}
return total;
}
function roundPercent(value) {
return Math.round(value * 10_000) / 10_000;
}
function expectedContributionPercent(multiplier, probabilityPercent) {
return contributionPPM(decimal(multiplier), percentToWeightPPM(probabilityPercent)) / 10_000;
}
function contributionPPM(multiplier, weightPPM) {
return Math.trunc((multiplierToPPM(multiplier) * weightPPM) / ppmScale);
}
function multiplierToPPM(value) {
return Math.round(decimal(value) * ppmScale);
}