转盘
This commit is contained in:
parent
743e4dc1c8
commit
d876ff6985
@ -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",
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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<CPRelationshipDto> {
|
||||
serverTimeMs?: number;
|
||||
}
|
||||
|
||||
export interface CPApplicationPageDto extends ApiPage<CPApplicationDto> {
|
||||
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<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> {
|
||||
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 RawRelation = CPRelationConfigDto & Record<string, unknown>;
|
||||
type RawLevel = CPLevelDto & 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 {
|
||||
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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
<AdminListPage>
|
||||
@ -34,52 +55,76 @@ export function CPConfigPage() {
|
||||
</div>
|
||||
<div className={styles.summaryActions}>
|
||||
<Button
|
||||
disabled={page.loading}
|
||||
disabled={page.loading || activeLoading}
|
||||
startIcon={<RefreshOutlined fontSize="small" />}
|
||||
onClick={page.reload}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
{page.abilities.canUpdate ? (
|
||||
<Button
|
||||
disabled={page.loading}
|
||||
startIcon={<EditOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={page.openDrawer}
|
||||
>
|
||||
编辑配置
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={page.loading}
|
||||
startIcon={page.abilities.canUpdate ? <EditOutlined fontSize="small" /> : <SettingsOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={page.openDrawer}
|
||||
>
|
||||
配置
|
||||
</Button>
|
||||
</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>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={relations}
|
||||
minWidth="1120px"
|
||||
rowKey={(item) => 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}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<CPConfigDrawer page={page} />
|
||||
<CPConfigDialog page={page} />
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Drawer anchor="right" open={page.drawerOpen} onClose={page.saving ? undefined : page.closeDrawer}>
|
||||
<form className={["form-drawer", styles.configDrawer].join(" ")} onSubmit={page.submit}>
|
||||
<div className={styles.configDrawerHeader}>
|
||||
<h2>CP配置</h2>
|
||||
<Dialog
|
||||
className={styles.configDialog}
|
||||
maxWidth={false}
|
||||
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}>
|
||||
<span>{(page.form.relations || []).length} 个关系</span>
|
||||
<span>{page.activeRelationCount} 个启用</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.configDrawerBody}>
|
||||
</DialogTitle>
|
||||
<DialogContent className={styles.configDialogContent}>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">关系与等级</div>
|
||||
<div className={styles.relationEditorList}>
|
||||
@ -105,22 +150,24 @@ function CPConfigDrawer({ page }) {
|
||||
updateWeeklyRankReward={page.updateWeeklyRankReward}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<div className={["form-drawer__actions", styles.configDrawerActions].join(" ")}>
|
||||
</DialogContent>
|
||||
<DialogActions className={styles.configDialogActions}>
|
||||
<Button disabled={page.saving} type="button" onClick={page.closeDrawer}>
|
||||
取消
|
||||
{canUpdate ? "取消" : "关闭"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={<SaveOutlined fontSize="small" />}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
{canUpdate ? (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={page.saving ? <CircularProgress color="inherit" size={16} /> : <SaveOutlined fontSize="small" />}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Drawer>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) => (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.name}>{item.displayName || cpRelationLabels[item.relationType]}</span>
|
||||
<span className={styles.meta}>{item.relationType}</span>
|
||||
</div>
|
||||
),
|
||||
key: "userA",
|
||||
label: "用户A",
|
||||
width: "minmax(210px, 0.9fr)",
|
||||
render: (item) => <CPUserCell user={item.userA} />,
|
||||
},
|
||||
{
|
||||
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) => <CPUserCell user={item.userB} />,
|
||||
},
|
||||
{
|
||||
key: "expire",
|
||||
label: "申请过期",
|
||||
width: "minmax(130px, 0.45fr)",
|
||||
render: (item) => `${formatNumber(item.applicationExpireHours)} 小时`,
|
||||
key: "relationType",
|
||||
label: "类型",
|
||||
width: "minmax(110px, 0.4fr)",
|
||||
render: (item) => <RelationTypeBadge relationType={item.relationType} />,
|
||||
},
|
||||
{
|
||||
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) => <LevelPreview levels={item.levels || []} />,
|
||||
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) => <GiftCell gift={item.gift} />,
|
||||
},
|
||||
{
|
||||
key: "formedAt",
|
||||
label: "组成时间",
|
||||
width: "minmax(170px, 0.7fr)",
|
||||
render: (item) => <NullableTime value={item.formedAtMs} />,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
@ -352,36 +412,102 @@ function cpConfigColumns() {
|
||||
width: "minmax(110px, 0.4fr)",
|
||||
render: (item) => <StatusBadge status={item.status} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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) => <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 (
|
||||
<div className={styles.levelPreview}>
|
||||
{levels.map((level) => (
|
||||
<span className={styles.levelPill} key={level.level}>
|
||||
{level.level}级 {formatNumber(level.intimacyThreshold)}
|
||||
</span>
|
||||
))}
|
||||
<div className={styles.giftCell}>
|
||||
{gift.giftIconUrl ? <img alt="" className={styles.giftIcon} src={gift.giftIconUrl} /> : null}
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.name}>{name}</span>
|
||||
<span className={styles.meta}>{meta || "礼物"}</span>
|
||||
<span className={styles.amount}>{formatNumber(gift.giftValue)} 价值</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const active = status === "active";
|
||||
function RelationTypeBadge({ relationType }) {
|
||||
return (
|
||||
<span className={[styles.statusBadge, active ? styles.statusActive : styles.statusDisabled].join(" ")}>
|
||||
{active ? "启用" : "停用"}
|
||||
<span className={[styles.relationBadge, styles[`relationBadge_${relationType}`]].filter(Boolean).join(" ")}>
|
||||
{cpRelationLabels[relationType] || relationType || "-"}
|
||||
</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 }) {
|
||||
return (
|
||||
<div className={styles.summaryItem}>
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<ApiOperationId, ApiEndpoint> = {
|
||||
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,
|
||||
|
||||
56
src/shared/api/generated/schema.d.ts
vendored
56
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
324
src/shared/utils/rtpProbability.js
Normal file
324
src/shared/utils/rtpProbability.js
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user