From d876ff6985ca97d7f58d8ea7a111d18dff74cc55 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 18 Jun 2026 19:38:25 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BD=AC=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 24 ++ src/App.test.jsx | 24 ++ src/features/cp-config/api.ts | 197 ++++++++++- src/features/cp-config/cp-config.module.css | 150 +++++++- .../cp-config/hooks/useCPConfigPage.js | 119 ++++++- src/features/cp-config/pages/CPConfigPage.jsx | 329 +++++++++++++----- src/features/lucky-gift/configModel.js | 306 ++-------------- src/features/operations/hooks/useWheelPage.js | 195 +++-------- .../operations/hooks/useWheelPage.test.js | 49 ++- src/shared/api/generated/endpoints.ts | 16 + src/shared/api/generated/schema.d.ts | 56 +++ src/shared/utils/rtpProbability.js | 324 +++++++++++++++++ 12 files changed, 1240 insertions(+), 549 deletions(-) create mode 100644 src/shared/utils/rtpProbability.js diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 9a7283b..ffc5bc3 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -154,6 +154,30 @@ "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": { "get": { "operationId": "listRedPackets", diff --git a/src/App.test.jsx b/src/App.test.jsx index 25dff65..73c1155 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -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); }); diff --git a/src/features/cp-config/api.ts b/src/features/cp-config/api.ts index fd5b02b..3bd9bac 100644 --- a/src/features/cp-config/api.ts +++ b/src/features/cp-config/api.ts @@ -1,7 +1,66 @@ 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 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 { + serverTimeMs?: number; +} + +export interface CPApplicationPageDto extends ApiPage { + serverTimeMs?: number; +} + export interface CPLevelDto { level: number; intimacyThreshold: number; @@ -66,20 +125,55 @@ export interface CPConfigPayload { }; } -const path = "/v1/admin/activity/cp/config"; - export function getCPConfig(): Promise { - return apiRequest(path).then(normalizeConfig); + const endpoint = API_ENDPOINTS.getCPConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.getCPConfig), { method: endpoint.method }).then( + normalizeConfig, + ); } export function updateCPConfig(payload: CPConfigPayload): Promise { - return apiRequest(path, { body: payload, method: "PUT" }).then(normalizeConfig); + const endpoint = API_ENDPOINTS.updateCPConfig; + return apiRequest(apiEndpointPath(API_OPERATIONS.updateCPConfig), { + body: payload, + method: endpoint.method, + }).then(normalizeConfig); +} + +export function listCPRelationships(query: PageQuery = {}): Promise { + const endpoint = API_ENDPOINTS.listCPRelationships; + return apiRequest(apiEndpointPath(API_OPERATIONS.listCPRelationships), { + method: endpoint.method, + query, + }).then(normalizeRelationshipPage); +} + +export function listCPApplications(query: PageQuery = {}): Promise { + const endpoint = API_ENDPOINTS.listCPApplications; + return apiRequest(apiEndpointPath(API_OPERATIONS.listCPApplications), { + method: endpoint.method, + query, + }).then(normalizeApplicationPage); } type RawConfig = CPConfigDto & Record; type RawRelation = CPRelationConfigDto & Record; type RawLevel = CPLevelDto & Record; type RawWeeklyRank = CPWeeklyRankDto & Record; +type RawRelationship = CPRelationshipDto & Record; +type RawApplication = CPApplicationDto & Record; +type RawUser = CPUserProfileDto & Record; +type RawGift = CPGiftSnapshotDto & Record; +type RawRelationshipPage = ApiPage & { + relationships?: RawRelationship[]; + page_size?: number | string; + server_time_ms?: number | string; +} & Record; +type RawApplicationPage = ApiPage & { + applications?: RawApplication[]; + page_size?: number | string; + server_time_ms?: number | string; +} & Record; function normalizeConfig(raw: RawConfig): CPConfigDto { 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 { const relationType = stringValue(value).toLowerCase(); if (relationType === "brother" || relationType === "sister") { @@ -161,6 +345,11 @@ function stringValue(value: unknown) { 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) { const number = Number(value || 0); return Number.isFinite(number) ? number : 0; diff --git a/src/features/cp-config/cp-config.module.css b/src/features/cp-config/cp-config.module.css index 409ebd9..8fa131e 100644 --- a/src/features/cp-config/cp-config.module.css +++ b/src/features/cp-config/cp-config.module.css @@ -38,6 +38,77 @@ 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 { display: flex; flex-direction: column; @@ -70,6 +141,16 @@ background: #e2e8f0; } +.statusPending { + color: #92400e; + background: #fef3c7; +} + +.statusDanger { + color: #b91c1c; + background: #fee2e2; +} + .levelPreview { display: flex; flex-wrap: wrap; @@ -95,31 +176,44 @@ gap: 16px; } -.configDrawer { - box-sizing: border-box; - width: min(980px, calc(100vw - 48px)); - height: 100vh; - max-height: 100vh; - grid-template-rows: auto minmax(0, 1fr) auto; - overflow: hidden; - padding: var(--space-5); +.configDialogPaper { + width: min(1040px, calc(100vw - 32px)) !important; + max-width: min(1040px, calc(100vw - 32px)) !important; + border-radius: var(--radius-card); + background: var(--bg-card); + box-shadow: var(--shadow-dialog); } -.configDrawerHeader { +.configDialogForm { + display: grid; + max-height: calc(100vh - 56px); + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; +} + +.configDialogTitle { display: flex; align-items: center; justify-content: space-between; 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); } -.configDrawerHeader h2 { +.configDialogTitle h2 { margin: 0; font-size: 20px; font-weight: 800; } +.configDialogTitle span { + display: inline-flex; + margin-top: 4px; + color: var(--text-tertiary); + font-size: 12px; + font-weight: 650; +} + .configDrawerStats { display: flex; flex-wrap: wrap; @@ -140,14 +234,18 @@ font-weight: 700; } -.configDrawerBody { +.configDialogContent { + display: grid; + gap: 18px; min-height: 0; overflow: auto; - padding-right: 2px; + padding: var(--space-5) !important; } -.configDrawerActions { - padding-top: var(--space-3); +.configDialogActions { + 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); background: var(--bg-card); } @@ -238,14 +336,28 @@ justify-content: flex-end; } - .configDrawer { - width: 100vw; - padding: var(--space-4); + .tabsBar { + overflow-x: auto; } - .configDrawerHeader { + .configDialogPaper { + width: calc(100vw - 24px) !important; + max-width: calc(100vw - 24px) !important; + margin: 12px !important; + } + + .configDialogTitle { align-items: flex-start; 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, diff --git a/src/features/cp-config/hooks/useCPConfigPage.js b/src/features/cp-config/hooks/useCPConfigPage.js index 77ea2d1..89a3c51 100644 --- a/src/features/cp-config/hooks/useCPConfigPage.js +++ b/src/features/cp-config/hooks/useCPConfigPage.js @@ -1,5 +1,5 @@ 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 { listResourceGroups } from "@/features/resources/api"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; @@ -14,10 +14,20 @@ export const cpWeeklyRankRanks = [1, 2, 3]; const defaultExpireHours = 24; const defaultThresholds = [0, 10000, 30000, 60000, 100000]; +const listPageSize = 50; +const emptyListPage = () => ({ items: [], page: 1, pageSize: listPageSize, serverTimeMs: 0, total: 0 }); export function useCPConfigPage() { const abilities = useCPConfigAbilities(); 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 [form, setForm] = useState({ relations: defaultRelations().map(relationToForm), @@ -33,7 +43,7 @@ export function useCPConfigPage() { [config.relations], ); - const reload = useCallback(async () => { + const reloadConfig = useCallback(async () => { setLoading(true); try { const [configResult, groupResult] = await Promise.allSettled([ @@ -62,8 +72,98 @@ export function useCPConfigPage() { }, [showToast]); useEffect(() => { - void reload(); - }, [reload]); + void reloadConfig(); + }, [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 = () => { setForm({ @@ -158,16 +258,27 @@ export function useCPConfigPage() { return { abilities, + activeTab, activeRelationCount, + applications, + applicationsError, + applicationsLoading, closeDrawer, config, drawerOpen, form, loading, + loadApplications, + loadRelationships, openDrawer, reload, + reloadActiveTab, + relationships, + relationshipsError, + relationshipsLoading, resourceGroups, saving, + setActiveTab, submit, updateLevel, updateRelation, diff --git a/src/features/cp-config/pages/CPConfigPage.jsx b/src/features/cp-config/pages/CPConfigPage.jsx index 60de32a..78f4b2d 100644 --- a/src/features/cp-config/pages/CPConfigPage.jsx +++ b/src/features/cp-config/pages/CPConfigPage.jsx @@ -1,23 +1,44 @@ import EditOutlined from "@mui/icons-material/EditOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; 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 { useMemo } from "react"; import { cpRelationLabels, cpWeeklyRankRanks, useCPConfigPage } from "@/features/cp-config/hooks/useCPConfigPage.js"; import styles from "@/features/cp-config/cp-config.module.css"; import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; +import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; import { Button } from "@/shared/ui/Button.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { DataTable } from "@/shared/ui/DataTable.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() { const page = useCPConfigPage(); 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 ( @@ -34,52 +55,76 @@ export function CPConfigPage() {
- {page.abilities.canUpdate ? ( - - ) : null} +
- +
+ page.setActiveTab(value)}> + + + +
+ item.relationType} + columns={isApplicationsTab ? applicationColumns : relationshipColumns} + emptyLabel="当前无数据" + items={activePage.items} + 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} /> - +
); } -function CPConfigDrawer({ page }) { - const disabled = !page.abilities.canUpdate || page.saving || page.loading; +function CPConfigDialog({ page }) { + const canUpdate = page.abilities.canUpdate; + const disabled = !canUpdate || page.saving || page.loading; return ( - -
-
-

CP配置

+ + + +
+

CP配置

+ 关系等级、解除费用和 CP 周榜奖励 +
{(page.form.relations || []).length} 个关系 {page.activeRelationCount} 个启用
-
-
+ +
关系与等级
@@ -105,22 +150,24 @@ function CPConfigDrawer({ page }) { updateWeeklyRankReward={page.updateWeeklyRankReward} />
-
-
+ + - -
+ {canUpdate ? ( + + ) : null} +
-
+ ); } @@ -309,42 +356,55 @@ function LevelEditor({ disabled, level, relationType, resourceGroups, updateLeve ); } -function cpConfigColumns() { +function cpRelationshipColumns(serverTimeMs) { return [ { - key: "relation", - label: "关系", - width: "minmax(140px, 0.45fr)", - render: (item) => ( -
- {item.displayName || cpRelationLabels[item.relationType]} - {item.relationType} -
- ), + key: "userA", + label: "用户A", + width: "minmax(210px, 0.9fr)", + render: (item) => , }, { - key: "maxCount", - label: "数量上限", - width: "minmax(120px, 0.45fr)", - render: (item) => (item.relationType === "cp" ? "1(固定)" : formatNumber(item.maxCountPerUser)), + key: "userB", + label: "用户B", + width: "minmax(210px, 0.9fr)", + render: (item) => , }, { - key: "expire", - label: "申请过期", - width: "minmax(130px, 0.45fr)", - render: (item) => `${formatNumber(item.applicationExpireHours)} 小时`, + key: "relationType", + label: "类型", + width: "minmax(110px, 0.4fr)", + render: (item) => , }, { - key: "breakupCost", - label: "解除费用", - width: "minmax(130px, 0.45fr)", - render: (item) => `${formatNumber(item.breakupCostCoins)} 金币`, + key: "days", + label: "天数", + width: "minmax(90px, 0.35fr)", + render: (item) => formatRelationDays(item, serverTimeMs), }, { - key: "levels", - label: "等级亲密值", - width: "minmax(360px, 1.2fr)", - render: (item) => , + key: "level", + label: "等级", + width: "minmax(90px, 0.35fr)", + 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) => , + }, + { + key: "formedAt", + label: "组成时间", + width: "minmax(170px, 0.7fr)", + render: (item) => , }, { key: "status", @@ -352,36 +412,102 @@ function cpConfigColumns() { width: "minmax(110px, 0.4fr)", render: (item) => , }, + ]; +} + +function cpApplicationColumns() { + return [ { - key: "updatedAt", - label: "更新时间", - width: "minmax(180px, 0.6fr)", - render: (item) => (item.updatedAtMs ? formatMillis(item.updatedAtMs) : "-"), + key: "requester", + label: "申请方", + width: "minmax(210px, 0.9fr)", + render: (item) => , + }, + { + key: "target", + label: "接收方", + width: "minmax(210px, 0.9fr)", + render: (item) => , + }, + { + key: "relationType", + label: "申请类型", + width: "minmax(120px, 0.45fr)", + render: (item) => , + }, + { + key: "gift", + label: "申请礼物/价值", + width: "minmax(240px, 1fr)", + render: (item) => , + }, + { + key: "status", + label: "状态", + width: "minmax(110px, 0.45fr)", + render: (item) => , + }, + { + key: "createdAt", + label: "申请时间", + width: "minmax(170px, 0.7fr)", + render: (item) => , + }, + { + key: "decidedAt", + label: "处理时间", + width: "minmax(170px, 0.7fr)", + render: (item) => , }, ]; } -function LevelPreview({ levels }) { +function CPUserCell({ user }) { + if (!user?.userId && !user?.displayUserId && !user?.username) { + return "-"; + } + return ; +} + +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 ( -
- {levels.map((level) => ( - - {level.level}级 {formatNumber(level.intimacyThreshold)} - - ))} +
+ {gift.giftIconUrl ? : null} +
+ {name} + {meta || "礼物"} + {formatNumber(gift.giftValue)} 价值 +
); } -function StatusBadge({ status }) { - const active = status === "active"; +function RelationTypeBadge({ relationType }) { return ( - - {active ? "启用" : "停用"} + + {cpRelationLabels[relationType] || relationType || "-"} ); } +function StatusBadge({ status }) { + const normalized = String(status || "").toLowerCase(); + return ( + + {statusLabel(normalized)} + + ); +} + +function NullableTime({ value }) { + return value ? : "-"; +} + function SummaryItem({ children, label }) { return (
@@ -408,6 +534,45 @@ function resourceGroupLabel(group, 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) { return Number(value || 0).toLocaleString("zh-CN"); } diff --git a/src/features/lucky-gift/configModel.js b/src/features/lucky-gift/configModel.js index e10b0df..c1f1b47 100644 --- a/src/features/lucky-gift/configModel.js +++ b/src/features/lucky-gift/configModel.js @@ -1,8 +1,15 @@ 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 minimumCorrectedProbabilityPercent = 0.01; -const ppmScale = 1_000_000; const defaultStageTiers = { novice: [ @@ -170,42 +177,20 @@ export function updateStageTier(stages, stageKey, tierIndex, patch) { } export function correctStageTierProbabilities(stages, stageKey, targetRTPPercent) { - const targetRTP = decimal(targetRTPPercent); return stages.map((stage) => { if (stage.stage !== stageKey) { return stage; } - const enabledTiers = stage.tiers - .map((item, index) => ({ index, multiplier: decimal(item.multiplier), tier: item })) - .filter((item) => item.tier.enabled !== false); - const baseTiers = enabledTiers.filter( - (item) => item.tier.rewardSource === "base_rtp" && 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 || positiveTiers.length === 0) { + const probabilityByIndex = correctRTPProbabilities(stage.tiers, targetRTPPercent, { + multiplier: (item) => item.multiplier, + participatesInRTP: (item) => item.rewardSource === "base_rtp", + }); + if (!probabilityByIndex) { 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 { ...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) { // 与 activity-service 的发布校验保持同一口径:只有启用奖档参与 100% 概率闭合; // 禁用奖档只是草稿行,不能让前端显示通过但服务端按启用集合拒绝。 - return (stage?.tiers || []).reduce((sum, item) => { - if (item?.enabled === false) { - return sum; - } - return sum + decimal(item.probabilityPercent); - }, 0); + return probabilityTotal(stage?.tiers || []); } export function stageExpectedRTP(stage) { // 阶段期望 RTP 是发布时真正校验的概率口径:只统计启用的基础 RTP 奖档,活动补贴和表现奖励不参与基础返奖率。 - return (stage?.tiers || []).reduce((sum, item) => { - if (item?.enabled === false || item?.rewardSource !== "base_rtp") { - return sum; - } - return sum + expectedContributionPercent(item.multiplier, item.probabilityPercent); - }, 0); + return expectedRTPPercent(stage?.tiers || [], { + multiplier: (item) => item.multiplier, + participatesInRTP: (item) => item.rewardSource === "base_rtp", + }); } export function tierRTPContribution(tier) { - if (tier?.enabled === false || tier?.rewardSource !== "base_rtp") { - return 0; - } - return expectedContributionPercent(tier.multiplier, tier.probabilityPercent); + return tierRTPContributionPercent(tier, { + multiplier: (item) => item.multiplier, + participatesInRTP: (item) => item.rewardSource === "base_rtp", + }); } export function assignGeneratedTierIDs(stages) { @@ -313,229 +291,6 @@ function cloneStages(stages) { 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 = {}) { return { 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) { const number = Number(value || 0); return Number.isFinite(number) ? Math.round(number) : 0; diff --git a/src/features/operations/hooks/useWheelPage.js b/src/features/operations/hooks/useWheelPage.js index bf51659..47dd6df 100644 --- a/src/features/operations/hooks/useWheelPage.js +++ b/src/features/operations/hooks/useWheelPage.js @@ -5,12 +5,9 @@ import { wheelConfigFormSchema } from "@/features/operations/schema"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; +import { applyProbabilityMap, correctRTPProbabilities, decimal } from "@/shared/utils/rtpProbability.js"; 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([ "drawPriceCoins", "targetRTPPercent", @@ -82,7 +79,10 @@ export function useWheelPage() { 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, { errorMessage: "获取资源组失败", initialData: { items: [] }, @@ -175,14 +175,23 @@ export function useWheelPage() { return; } 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"); }; const moveTier = (fromIndex, toIndex) => { 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; } const tiers = [...current.tiers]; @@ -275,20 +284,18 @@ function configToForm(config) { } function normalizeTiers(tiers, wheelId) { - return (tiers || []).map( - (tier, index) => ({ - displayName: tier.displayName || "", - enabled: true, - metadataJson: tier.metadataJson || "{}", - probabilityPercent: tier.probabilityPercent || 0, - rewardCoins: tier.rewardCoins || 0, - rewardCount: tier.rewardCount || 0, - rewardId: tier.rewardId || "", - rewardType: tier.rewardType || "coin", - rtpValueCoins: tier.rewardType === "prop" || tier.rewardType === "dress" ? 0 : tier.rtpValueCoins || 0, - tierId: tier.tierId || `${wheelId}-${index + 1}`, - }), - ); + return (tiers || []).map((tier, index) => ({ + displayName: tier.displayName || "", + enabled: true, + metadataJson: tier.metadataJson || "{}", + probabilityPercent: tier.probabilityPercent || 0, + rewardCoins: tier.rewardCoins || 0, + rewardCount: tier.rewardCount || 0, + rewardId: tier.rewardId || "", + rewardType: tier.rewardType || "coin", + rtpValueCoins: tier.rewardType === "prop" || tier.rewardType === "dress" ? 0 : tier.rtpValueCoins || 0, + tierId: tier.tierId || `${wheelId}-${index + 1}`, + })); } function isGeneratedPlaceholderTiers(tiers, wheelId) { @@ -363,7 +370,9 @@ function groupItemToTier(item, wheelId, index) { resource_type: resource.resourceType || "", }; return { - displayName: isCoin ? "金币" : resource.name || resource.resourceCode || `资源 ${resource.resourceId || index + 1}`, + displayName: isCoin + ? "金币" + : resource.name || resource.resourceCode || `资源 ${resource.resourceId || index + 1}`, enabled: true, metadataJson: JSON.stringify(metadata), probabilityPercent: 0, @@ -385,144 +394,36 @@ export function generateWheelTierProbabilities(form) { if (!tiers.length) { return tiers; } - - const ppm = tiers.map(() => minimumTierProbabilityPPM); - const randomIndexes = []; - const rtpIndexes = []; - const rtpValues = new Map(); - - 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); - } + // 转盘没有幸运礼物的倍率字段,这里把每个奖档的 RTP 价值换算成“相对单抽价格的倍率”。 + // 道具/装扮不计入 RTP,因此只拿公共公式保留的最小正概率;全无 RTP 奖档时退化为均分,保证仍可发布配置。 + const probabilityByIndex = correctRTPProbabilities(tiers, form.targetRTPPercent, { + fallback: "equal", + multiplier: (tier) => wheelTierMultiplier(tier, form.drawPriceCoins), + participatesInRTP: wheelTierParticipatesInRTP, }); - - 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); + return applyProbabilityMap(tiers, probabilityByIndex, (value) => Number(value.toFixed(4))); } -function distributeRtpProbability(ppm, sortedIndexes, rtpValues, availablePPM, targetCoins) { - if (availablePPM <= 0 || !sortedIndexes.length) { - return; +function wheelTierMultiplier(tier, drawPriceCoins) { + const drawPrice = decimal(drawPriceCoins); + if (drawPrice <= 0) { + return 0; } - if (sortedIndexes.length === 1) { - 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; + return wheelTierRTPValueCoins(tier) / drawPrice; } -function distributeEqual(ppm, indexes, totalPPM) { - 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) { +function wheelTierRTPValueCoins(tier) { if (tier.rewardType === "coin") { - return numberValue(tier.rewardCoins || tier.rtpValueCoins); + return decimal(tier.rewardCoins || tier.rtpValueCoins); } if (tier.rewardType === "gift") { - return numberValue(tier.rtpValueCoins); + return decimal(tier.rtpValueCoins); } return 0; } -function numberValue(value) { - const number = Number(value); - 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 wheelTierParticipatesInRTP(tier) { + return tier.rewardType === "coin" || tier.rewardType === "gift"; } function resourceTypeToRewardType(resourceType) { diff --git a/src/features/operations/hooks/useWheelPage.test.js b/src/features/operations/hooks/useWheelPage.test.js index be021d0..cf75fdd 100644 --- a/src/features/operations/hooks/useWheelPage.test.js +++ b/src/features/operations/hooks/useWheelPage.test.js @@ -22,13 +22,13 @@ function probabilityPPM(tiers) { 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) => { 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") { - return sum + (item.probabilityPercent / 100) * Number(item.rtpValueCoins || 0); + return sum + (Number(item.rtpValueCoins || 0) / drawPriceCoins) * Number(item.probabilityPercent || 0); } return sum; }, 0); @@ -78,14 +78,23 @@ describe("wheel prize probability generation", () => { tiers: generateWheelTierProbabilities({ drawPriceCoins: 100, 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", }).success, ).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({ drawPriceCoins: 10000, targetRTPPercent: 90, @@ -98,12 +107,31 @@ describe("wheel prize probability generation", () => { }); expect(probabilityPPM(result)).toBe(1_000_000); - expect(expectedPayoutCoins(result)).toBeCloseTo(9000, 0); - expect(result[2].probabilityPercent).toBe(result[3].probabilityPercent); - expect(result[0].probabilityPercent + result[1].probabilityPercent).toBeGreaterThan(95); + expect(wheelExpectedRTP(result, 10000)).toBeCloseTo(90, 4); + expect(result[2].probabilityPercent).toBe(0.01); + 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({ drawPriceCoins: 10000, targetRTPPercent: 90, @@ -114,7 +142,6 @@ describe("wheel prize probability generation", () => { }); expect(probabilityPPM(result)).toBe(1_000_000); - expect(result[0].probabilityPercent).toBe(50); - expect(result[1].probabilityPercent).toBe(50); + expect(result.map((item) => item.probabilityPercent)).toEqual([50, 50]); }); }); diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 41a164b..7a7a9eb 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -145,6 +145,8 @@ export const API_OPERATIONS = { listCoinSellerLedger: "listCoinSellerLedger", listCoinSellers: "listCoinSellers", listCountries: "listCountries", + listCPApplications: "listCPApplications", + listCPRelationships: "listCPRelationships", listCumulativeRechargeRewardGrants: "listCumulativeRechargeRewardGrants", listDiceRobots: "listDiceRobots", listEmojiPackCategories: "listEmojiPackCategories", @@ -1211,6 +1213,20 @@ export const API_ENDPOINTS: Record = { permission: "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: { method: "GET", operationId: API_OPERATIONS.listCumulativeRechargeRewardGrants, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 7baa4df..858985f 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -116,6 +116,38 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -3733,6 +3765,30 @@ export interface operations { 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: { parameters: { query?: never; diff --git a/src/shared/utils/rtpProbability.js b/src/shared/utils/rtpProbability.js new file mode 100644 index 0000000..133663a --- /dev/null +++ b/src/shared/utils/rtpProbability.js @@ -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); +}