Compare commits

..

4 Commits

Author SHA1 Message Date
zhx
57231bc95a 可配置 2026-06-22 20:59:41 +08:00
zhx
0789e02c6a 抽奖相关 2026-06-18 22:41:59 +08:00
zhx
3b21bd1133 增加概率 2026-06-18 20:26:00 +08:00
zhx
d876ff6985 转盘 2026-06-18 19:38:25 +08:00
27 changed files with 2182 additions and 605 deletions

View File

@ -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",
@ -2857,6 +2881,18 @@
"x-permissions": ["resource-shop:update"]
}
},
"/admin/resource-shop/purchase-orders": {
"get": {
"operationId": "listResourceShopPurchaseOrders",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "resource-shop:view",
"x-permissions": ["resource-shop:view"]
}
},
"/admin/resource-shop/items/{shop_item_id}/disable": {
"post": {
"operationId": "disableResourceShopItem",
@ -3320,7 +3356,7 @@
"operationId": "getHumanRoomRobotConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
"$ref": "#/components/responses/HumanRoomRobotConfigResponse"
}
},
"x-permission": "room-robot:view",
@ -3328,9 +3364,18 @@
},
"put": {
"operationId": "updateHumanRoomRobotConfig",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HumanRoomRobotConfigInput"
}
}
}
},
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
"$ref": "#/components/responses/HumanRoomRobotConfigResponse"
}
},
"x-permission": "room-robot:update",
@ -4657,6 +4702,16 @@
}
}
},
"HumanRoomRobotConfigResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseHumanRoomRobotConfig"
}
}
}
},
"LogPageResponse": {
"description": "OK",
"content": {
@ -5013,6 +5068,21 @@
}
]
},
"ApiResponseHumanRoomRobotConfig": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/HumanRoomRobotConfig"
}
}
}
]
},
"ApiResponseLogPage": {
"allOf": [
{
@ -5426,6 +5496,111 @@
}
}
},
"HumanRoomRobotCountryRule": {
"type": "object",
"required": ["countryCode", "maxRoomCount"],
"properties": {
"countryCode": {
"type": "string"
},
"maxRoomCount": {
"type": "integer"
}
}
},
"HumanRoomRobotConfig": {
"type": "object",
"properties": {
"candidateRoomMaxOnline": {
"type": "integer"
},
"countryLimitEnabled": {
"type": "boolean"
},
"countryRules": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HumanRoomRobotCountryRule"
}
},
"enabled": {
"type": "boolean"
},
"giftIds": {
"type": "array",
"items": {
"type": "string"
}
},
"luckyComboMax": {
"type": "integer"
},
"luckyComboMin": {
"type": "integer"
},
"luckyGiftIds": {
"type": "array",
"items": {
"type": "string"
}
},
"luckyPauseMaxMs": {
"type": "integer"
},
"luckyPauseMinMs": {
"type": "integer"
},
"maxGiftSenders": {
"type": "integer"
},
"normalGiftIntervalMaxMs": {
"type": "integer"
},
"normalGiftIntervalMinMs": {
"type": "integer"
},
"robotReplaceMaxMs": {
"type": "integer"
},
"robotReplaceMinMs": {
"type": "integer"
},
"robotStayMaxMs": {
"type": "integer"
},
"robotStayMinMs": {
"type": "integer"
},
"roomFullStopOnline": {
"type": "integer"
},
"roomTargetMaxOnline": {
"type": "integer"
},
"roomTargetMinOnline": {
"type": "integer"
},
"superLuckyGiftIds": {
"type": "array",
"items": {
"type": "string"
}
},
"updatedAtMs": {
"type": "integer"
},
"updatedByAdminId": {
"type": "integer"
}
}
},
"HumanRoomRobotConfigInput": {
"allOf": [
{
"$ref": "#/components/schemas/HumanRoomRobotConfig"
}
]
},
"Log": {
"type": "object",
"additionalProperties": true

View File

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

View File

@ -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;

View File

@ -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,

View File

@ -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,

View File

@ -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");
}

View File

@ -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;

View File

@ -108,7 +108,7 @@ describe("lucky gift config payload", () => {
const novice = nextStages.find((stage) => stage.stage === "novice");
expect(stageProbabilityTotal(novice)).toBeCloseTo(100, 6);
expect(stageExpectedRTP(novice)).toBeCloseTo(199.955, 6);
expect(stageExpectedRTP(novice)).toBeCloseTo(199.9995, 6);
expect(novice.tiers.every((tier) => Number(tier.probabilityPercent) > 0)).toBe(true);
});

View File

@ -30,6 +30,7 @@ test("gift diamond ratio APIs use operations paths", async () => {
await updateGiftDiamondRatios({
regionId: 1001,
ratios: { lucky: "40.00", normal: "30.00", super_lucky: "50.00" },
returnCoinRatios: { lucky: "10.00", normal: "30.00", super_lucky: "1.00" },
});
const [getUrl, getInit] = vi.mocked(fetch).mock.calls[0];
@ -43,6 +44,7 @@ test("gift diamond ratio APIs use operations paths", async () => {
expect(JSON.parse(String(putInit?.body))).toMatchObject({
regionId: 1001,
ratios: { lucky: "40.00", normal: "30.00", super_lucky: "50.00" },
returnCoinRatios: { lucky: "10.00", normal: "30.00", super_lucky: "1.00" },
});
});

View File

@ -16,6 +16,7 @@ export interface GiftDiamondRatioItem {
effectiveRegionId: number;
giftTypeCode: string;
ratioPercent: string;
returnCoinRatioPercent: string;
regionId: number;
status: string;
updatedAtMs?: number;
@ -226,6 +227,7 @@ export function getGiftDiamondRatios(regionId: string | number): Promise<GiftDia
export function updateGiftDiamondRatios(payload: {
regionId: number;
ratios: Record<string, string>;
returnCoinRatios: Record<string, string>;
}): Promise<GiftDiamondRatioResponse> {
return apiRequest<GiftDiamondRatioResponse, typeof payload>("/v1/admin/operations/gift-diamond-ratios", {
body: payload,

View File

@ -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) {
@ -349,7 +356,7 @@ function poolLabel(wheelId) {
return (wheelPools.find((pool) => pool.key === wheelId) || wheelPools[0]).label;
}
function groupItemToTier(item, wheelId, index) {
export function groupItemToTier(item, wheelId, index) {
const resource = item.resource || {};
const isCoin = item.itemType === "wallet_asset" && item.walletAssetType === "COIN";
const rewardType = isCoin ? "coin" : resourceTypeToRewardType(resource.resourceType);
@ -357,13 +364,20 @@ function groupItemToTier(item, wheelId, index) {
const metadata = {
animation_url: isCoin ? "" : resource.animationUrl || "",
asset_url: resource.assetUrl || "",
coinPrice: coinValue,
coin_price: coinValue,
displayValue: coinValue,
display_value: coinValue,
preview_url: resource.previewUrl || resource.assetUrl || "",
resource_code: resource.resourceCode || "",
resource_id: resource.resourceId || 0,
resource_type: resource.resourceType || "",
value: coinValue,
};
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 +399,50 @@ 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, {
baselineProbability: (tier, index, context) =>
wheelTierBaselineProbability(tier, index, context, form.drawPriceCoins),
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;
function wheelTierBaselineProbability(tier, index, context, drawPriceCoins) {
if (!wheelTierParticipatesInRTP(tier)) {
return 0;
}
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;
const multiplier = wheelTierMultiplier(tier, drawPriceCoins);
if (multiplier <= 0) {
return 0;
}
const maxMultiplier = Math.max(multiplier, ...(context.positiveTiers || []).map((item) => item.multiplier));
return 0.0001 * (maxMultiplier / multiplier);
}
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) {

View File

@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { wheelConfigFormSchema } from "@/features/operations/schema";
import { generateWheelTierProbabilities, wheelPools } from "@/features/operations/hooks/useWheelPage.js";
import {
generateWheelTierProbabilities,
groupItemToTier,
wheelPools,
} from "@/features/operations/hooks/useWheelPage.js";
function tier(overrides) {
return {
@ -22,19 +26,46 @@ 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);
}
describe("wheel prize probability generation", () => {
test("stores resource coin price in metadata for prop and dress display", () => {
const result = groupItemToTier(
{
itemType: "resource",
quantity: 1,
resource: {
assetUrl: "https://example.com/a.png",
coinPrice: 1314,
name: "道具",
previewUrl: "https://example.com/p.png",
resourceCode: "prop_a",
resourceId: 326,
resourceType: "prop",
},
},
"classic",
0,
);
const metadata = JSON.parse(result.metadataJson);
expect(result.rewardType).toBe("prop");
expect(result.rtpValueCoins).toBe(0);
expect(metadata.coin_price).toBe(1314);
expect(metadata.display_value).toBe(1314);
expect(metadata.value).toBe(1314);
});
test("uses eight prize slots for the classic wheel", () => {
const classicPool = wheelPools.find((pool) => pool.key === "classic");
const tiers = Array.from({ length: 8 }, (_, index) =>
@ -78,14 +109,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 +138,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, 3);
expect(result[2].probabilityPercent).toBe(0.0001);
expect(result[3].probabilityPercent).toBe(0.0001);
});
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 +173,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]);
});
});

View File

@ -235,6 +235,29 @@
gap: var(--space-4);
}
.ratioTable {
display: grid;
grid-template-columns: minmax(160px, 0.8fr) repeat(2, minmax(180px, 1fr));
align-items: center;
gap: var(--space-3);
}
.ratioTableHeader {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 800;
}
.ratioRow {
display: contents;
}
.ratioType {
min-width: 0;
color: var(--text-primary);
font-weight: 760;
}
.wheelBody {
display: grid;
min-width: 0;
@ -626,10 +649,22 @@
}
@media (max-width: 720px) {
.ratioGrid {
.ratioGrid,
.ratioTable {
grid-template-columns: minmax(0, 1fr);
}
.ratioTableHeader {
display: none;
}
.ratioRow {
display: grid;
gap: var(--space-3);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border-soft);
}
.wheelBody,
.wheelConfigGrid,
.wheelSummaryGrid,

View File

@ -21,8 +21,14 @@ const giftTypes = [
const defaultRatios = {
normal: "100.00",
lucky: "100.00",
super_lucky: "100.00",
lucky: "10.00",
super_lucky: "1.00",
};
const defaultReturnCoinRatios = {
normal: "30.00",
lucky: "10.00",
super_lucky: "1.00",
};
export function GiftDiamondRatioPage() {
@ -30,7 +36,7 @@ export function GiftDiamondRatioPage() {
const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions();
const [regionId, setRegionId] = useState("0");
const [form, setForm] = useState(defaultRatios);
const [form, setForm] = useState({ ratios: defaultRatios, returnCoinRatios: defaultReturnCoinRatios });
const [saving, setSaving] = useState(false);
const queryFn = useCallback(() => getGiftDiamondRatios(regionId), [regionId]);
@ -40,13 +46,16 @@ export function GiftDiamondRatioPage() {
});
useEffect(() => {
const next = { ...defaultRatios };
const nextRatios = { ...defaultRatios };
const nextReturnCoinRatios = { ...defaultReturnCoinRatios };
for (const item of query.data?.items || []) {
if (Object.prototype.hasOwnProperty.call(next, item.giftTypeCode)) {
next[item.giftTypeCode] = item.ratioPercent || "100.00";
if (Object.prototype.hasOwnProperty.call(nextRatios, item.giftTypeCode)) {
nextRatios[item.giftTypeCode] = item.ratioPercent || defaultRatios[item.giftTypeCode];
nextReturnCoinRatios[item.giftTypeCode] =
item.returnCoinRatioPercent || defaultReturnCoinRatios[item.giftTypeCode];
}
}
setForm(next);
setForm({ ratios: nextRatios, returnCoinRatios: nextReturnCoinRatios });
}, [query.data]);
const selectedRegionLabel = useMemo(() => {
@ -56,31 +65,42 @@ export function GiftDiamondRatioPage() {
return regionOptions.find((option) => option.value === regionId)?.label || `区域 ${regionId}`;
}, [regionId, regionOptions]);
const changeRatio = (giftType, value) => {
setForm((current) => ({ ...current, [giftType]: value }));
const changeRatio = (field, giftType, value) => {
setForm((current) => ({ ...current, [field]: { ...current[field], [giftType]: value } }));
};
const submit = async () => {
const payload = {};
const ratios = {};
const returnCoinRatios = {};
for (const giftType of giftTypes) {
const value = String(form[giftType.value] || "").trim();
const value = String(form.ratios[giftType.value] || "").trim();
const numeric = Number(value);
if (!value || !Number.isFinite(numeric) || numeric < 0 || numeric > 100) {
showToast(`${giftType.label}比例必须在 0-100 之间`, "error");
showToast(`${giftType.label}主播钻石比例必须在 0-100 之间`, "error");
return;
}
payload[giftType.value] = numeric.toFixed(2);
ratios[giftType.value] = numeric.toFixed(2);
const returnCoinValue = String(form.returnCoinRatios[giftType.value] || "").trim();
const returnCoinNumeric = Number(returnCoinValue);
if (!returnCoinValue || !Number.isFinite(returnCoinNumeric) || returnCoinNumeric < 0 || returnCoinNumeric > 100) {
showToast(`${giftType.label}返还金币比例必须在 0-100 之间`, "error");
return;
}
returnCoinRatios[giftType.value] = returnCoinNumeric.toFixed(2);
}
setSaving(true);
try {
const result = await updateGiftDiamondRatios({ regionId: Number(regionId), ratios: payload });
const next = { ...defaultRatios };
const result = await updateGiftDiamondRatios({ regionId: Number(regionId), ratios, returnCoinRatios });
const nextRatios = { ...defaultRatios };
const nextReturnCoinRatios = { ...defaultReturnCoinRatios };
for (const item of result.items || []) {
if (Object.prototype.hasOwnProperty.call(next, item.giftTypeCode)) {
next[item.giftTypeCode] = item.ratioPercent || "100.00";
if (Object.prototype.hasOwnProperty.call(nextRatios, item.giftTypeCode)) {
nextRatios[item.giftTypeCode] = item.ratioPercent || defaultRatios[item.giftTypeCode];
nextReturnCoinRatios[item.giftTypeCode] =
item.returnCoinRatioPercent || defaultReturnCoinRatios[item.giftTypeCode];
}
}
setForm(next);
setForm({ ratios: nextRatios, returnCoinRatios: nextReturnCoinRatios });
showToast("礼物钻石比例已保存", "success");
await query.reload();
} catch (err) {
@ -124,21 +144,40 @@ export function GiftDiamondRatioPage() {
<div className={styles.ratioHeader}>
<h2>{selectedRegionLabel}</h2>
</div>
<div className={styles.ratioGrid}>
<div className={styles.ratioTable}>
<div className={styles.ratioTableHeader}>礼物类型</div>
<div className={styles.ratioTableHeader}>主播钻石比例</div>
<div className={styles.ratioTableHeader}>返还金币比例</div>
{giftTypes.map((giftType) => (
<TextField
key={giftType.value}
label={giftType.label}
type="number"
size="small"
value={form[giftType.value] || ""}
inputProps={{ max: 100, min: 0, step: "0.01" }}
InputProps={{
endAdornment: <InputAdornment position="end">%</InputAdornment>,
}}
disabled={!abilities.canUpdate || saving}
onChange={(event) => changeRatio(giftType.value, event.target.value)}
/>
<div key={giftType.value} className={styles.ratioRow}>
<div className={styles.ratioType}>{giftType.label}</div>
<TextField
label="主播钻石比例"
type="number"
size="small"
value={form.ratios[giftType.value] || ""}
inputProps={{ max: 100, min: 0, step: "0.01" }}
InputProps={{
endAdornment: <InputAdornment position="end">%</InputAdornment>,
}}
disabled={!abilities.canUpdate || saving}
onChange={(event) => changeRatio("ratios", giftType.value, event.target.value)}
/>
<TextField
label="返还金币比例"
type="number"
size="small"
value={form.returnCoinRatios[giftType.value] || ""}
inputProps={{ max: 100, min: 0, step: "0.01" }}
InputProps={{
endAdornment: <InputAdornment position="end">%</InputAdornment>,
}}
disabled={!abilities.canUpdate || saving}
onChange={(event) =>
changeRatio("returnCoinRatios", giftType.value, event.target.value)
}
/>
</div>
))}
</div>
</section>

View File

@ -22,6 +22,7 @@ import {
listResourceGrants,
listGifts,
listResourceShopItems,
listResourceShopPurchaseOrders,
listResources,
lookupResourceGrantTarget,
updateGift,
@ -327,6 +328,12 @@ test("resource shop APIs use generated admin paths", async () => {
),
);
await listResourceShopPurchaseOrders({
page: 1,
page_size: 10,
resource_type: "avatar_frame",
user_keyword: "163000",
});
await listResourceShopItems({ page: 1, page_size: 10, resource_type: "avatar_frame", status: "active" });
await upsertResourceShopItems({
items: [
@ -343,11 +350,16 @@ test("resource shop APIs use generated admin paths", async () => {
await enableResourceShopItem(99);
await disableResourceShopItem(99);
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
const [upsertUrl, upsertInit] = vi.mocked(fetch).mock.calls[1];
const [enableUrl, enableInit] = vi.mocked(fetch).mock.calls[2];
const [disableUrl, disableInit] = vi.mocked(fetch).mock.calls[3];
const [purchaseUrl, purchaseInit] = vi.mocked(fetch).mock.calls[0];
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[1];
const [upsertUrl, upsertInit] = vi.mocked(fetch).mock.calls[2];
const [enableUrl, enableInit] = vi.mocked(fetch).mock.calls[3];
const [disableUrl, disableInit] = vi.mocked(fetch).mock.calls[4];
expect(String(purchaseUrl)).toContain("/api/v1/admin/resource-shop/purchase-orders?");
expect(String(purchaseUrl)).toContain("resource_type=avatar_frame");
expect(String(purchaseUrl)).toContain("user_keyword=163000");
expect(purchaseInit?.method).toBe("GET");
expect(String(listUrl)).toContain("/api/v1/admin/resource-shop/items?");
expect(String(listUrl)).toContain("resource_type=avatar_frame");
expect(String(listUrl)).toContain("status=active");

View File

@ -124,6 +124,25 @@ export interface ResourceShopItemDto {
updatedAtMs?: number;
}
export interface ResourceShopPurchaseOrderDto {
appCode?: string;
orderId: string;
commandId?: string;
userId?: string;
user?: ResourceGrantUserDto;
shopItemId?: number;
resourceId: number;
resource?: ResourceDto;
durationDays?: number;
priceCoin?: number;
status?: string;
walletTransactionId?: string;
resourceGrantId?: string;
entitlementId?: string;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface GiftDto {
appCode?: string;
giftId: string;
@ -352,6 +371,17 @@ export function listResourceShopItems(query: PageQuery = {}): Promise<ApiPage<Re
});
}
export function listResourceShopPurchaseOrders(query: PageQuery = {}): Promise<ApiPage<ResourceShopPurchaseOrderDto>> {
const endpoint = API_ENDPOINTS.listResourceShopPurchaseOrders;
return apiRequest<ApiPage<ResourceShopPurchaseOrderDto>>(
apiEndpointPath(API_OPERATIONS.listResourceShopPurchaseOrders),
{
method: endpoint.method,
query,
},
);
}
export function upsertResourceShopItems(payload: ResourceShopItemsPayload): Promise<ResourceShopItemDto[]> {
const endpoint = API_ENDPOINTS.upsertResourceShopItems;
return apiRequest<ResourceShopItemDto[], ResourceShopItemsPayload>(

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
@ -27,6 +27,7 @@ import {
listResourceGrants,
listResourceGroups,
listResourceShopItems,
listResourceShopPurchaseOrders,
listResources,
lookupResourceGrantTarget,
updateGift,
@ -482,6 +483,11 @@ export function useResourceShopPage() {
const [resourceType, setResourceType] = useState("");
const [page, setPage] = useState(1);
const [drawerOpen, setDrawerOpen] = useState(false);
const [purchaseDrawerOpen, setPurchaseDrawerOpen] = useState(false);
const [purchasePage, setPurchasePage] = useState(1);
const [purchaseUserKeyword, setPurchaseUserKeyword] = useState("");
const [purchaseResourceType, setPurchaseResourceType] = useState("");
const [purchaseKeyword, setPurchaseKeyword] = useState("");
const [drawerQuery, setDrawerQuery] = useState("");
const [drawerResourceType, setDrawerResourceType] = useState("");
const [batchDurationDays, setBatchDurationDays] = useState("1");
@ -505,6 +511,31 @@ export function useResourceShopPage() {
pageSize,
queryKey: ["resource-shop", filters, page],
});
const purchaseFilters = useMemo(
() => ({
keyword: purchaseKeyword,
resource_type: purchaseResourceType,
user_keyword: purchaseUserKeyword,
}),
[purchaseKeyword, purchaseResourceType, purchaseUserKeyword],
);
const fetchPurchaseOrders = useCallback(
(requestQuery) => {
if (!purchaseDrawerOpen) {
return Promise.resolve(emptyData);
}
return listResourceShopPurchaseOrders(requestQuery);
},
[purchaseDrawerOpen],
);
const purchaseResult = usePaginatedQuery({
errorMessage: "加载售卖记录失败",
fetcher: fetchPurchaseOrders,
filters: purchaseFilters,
page: purchasePage,
pageSize,
queryKey: ["resource-shop-purchase-orders", purchaseDrawerOpen, purchaseFilters, purchasePage],
});
const shopItemsByResourceId = useMemo(() => {
const items = new Map();
(result.data?.items || []).forEach((item) => {
@ -579,6 +610,15 @@ export function useResourceShopPage() {
setDraftByResourceId({});
};
const openPurchaseDrawer = () => {
setPurchaseDrawerOpen(true);
setPurchasePage(1);
};
const closePurchaseDrawer = () => {
setPurchaseDrawerOpen(false);
};
const toggleDraftResource = (resource) => {
if (!resource?.resourceId) {
return;
@ -692,12 +732,24 @@ export function useResourceShopPage() {
changeResourceType: resetSetter(setResourceType, setPage),
changeStatus: resetSetter(setStatus, setPage),
closeShopDrawer,
closePurchaseDrawer,
drawerOpen,
drawerQuery,
drawerResourceType,
filteredResourceOptions,
loadingAction,
openShopDrawer,
openPurchaseDrawer,
purchaseData: purchaseResult.data,
purchaseDrawerOpen,
purchaseError: purchaseResult.error,
purchaseKeyword,
purchaseLoading: purchaseResult.loading,
purchasePage,
purchaseResourceType,
purchaseUserKeyword,
reloadPurchaseOrders: purchaseResult.reload,
setPurchasePage,
resourceOptionsLoading,
resourceType,
resetFilters,
@ -707,6 +759,9 @@ export function useResourceShopPage() {
submitShopItems,
toggleDraftResource,
toggleShopItem,
changePurchaseKeyword: resetSetter(setPurchaseKeyword, setPurchasePage),
changePurchaseResourceType: resetSetter(setPurchaseResourceType, setPurchasePage),
changePurchaseUserKeyword: resetSetter(setPurchaseUserKeyword, setPurchasePage),
updateDraftItem,
};
}

View File

@ -1,8 +1,10 @@
import Add from "@mui/icons-material/Add";
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
import StorefrontOutlined from "@mui/icons-material/StorefrontOutlined";
import Checkbox from "@mui/material/Checkbox";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
@ -70,6 +72,39 @@ const shopColumns = [
},
];
const purchaseColumns = [
{
key: "user",
label: "用户信息",
width: "minmax(210px, 1fr)",
render: (item) => <PurchaseUser order={item} />,
},
{
key: "resourceType",
label: "道具类型",
width: "minmax(120px, 0.55fr)",
render: (item) => resourceTypeLabels[item.resource?.resourceType] || item.resource?.resourceType || "-",
},
{
key: "resource",
label: "购买素材",
width: "minmax(260px, 1.1fr)",
render: (item) => <ResourceIdentity resource={item.resource} />,
},
{
key: "price",
label: "购买价格",
width: "minmax(130px, 0.55fr)",
render: (item) => coinPriceLabel(item.priceCoin),
},
{
key: "time",
label: "购买时间",
width: "minmax(170px, 0.7fr)",
render: (item) => formatMillis(item.createdAtMs),
},
];
export function ResourceShopPage() {
const page = useResourceShopPage();
const items = page.data.items || [];
@ -115,11 +150,18 @@ export function ResourceShopPage() {
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canUpdateShop ? (
<AdminActionIconButton label="添加售卖资源" primary onClick={page.openShopDrawer}>
<Add fontSize="small" />
</AdminActionIconButton>
) : null
<>
{page.abilities.canViewShop ? (
<AdminActionIconButton label="售卖记录" onClick={page.openPurchaseDrawer}>
<ReceiptLongOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canUpdateShop ? (
<AdminActionIconButton label="添加售卖资源" primary onClick={page.openShopDrawer}>
<Add fontSize="small" />
</AdminActionIconButton>
) : null}
</>
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
@ -143,6 +185,7 @@ export function ResourceShopPage() {
</AdminListBody>
</DataState>
<ResourceShopDrawer page={page} />
<ResourceShopPurchaseDrawer page={page} />
</AdminListPage>
);
}
@ -227,6 +270,69 @@ function ResourceShopDrawer({ page }) {
);
}
function ResourceShopPurchaseDrawer({ page }) {
const data = page.purchaseData || {};
const items = data.items || [];
const total = data.total || 0;
const columns = purchaseColumns.map((column) => {
if (column.key === "user") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索用户ID、短号、靓号、昵称",
value: page.purchaseUserKeyword,
onChange: page.changePurchaseUserKeyword,
}),
};
}
if (column.key === "resourceType") {
return {
...column,
filter: createOptionsColumnFilter({
options: resourceShopTypeFilters,
placeholder: "搜索道具类型",
value: page.purchaseResourceType,
onChange: page.changePurchaseResourceType,
}),
};
}
if (column.key === "resource") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索素材名称、素材编码、订单号",
value: page.purchaseKeyword,
onChange: page.changePurchaseKeyword,
}),
};
}
return column;
});
return (
<SideDrawer open={page.purchaseDrawerOpen} title="售卖记录" width="wide" onClose={page.closePurchaseDrawer}>
<DataState error={page.purchaseError} loading={page.purchaseLoading} onRetry={page.reloadPurchaseOrders}>
<DataTable
columns={columns}
items={items}
minWidth="980px"
pagination={
total > 0
? {
page: page.purchasePage,
pageSize: data.pageSize || 50,
total,
onPageChange: page.setPurchasePage,
}
: undefined
}
rowKey={(item) => item.orderId}
/>
</DataState>
</SideDrawer>
);
}
function ResourceSelectionRow({ disabled, page, resource }) {
const resourceId = String(resource.resourceId);
const selected = page.selectedResourceIds.has(resourceId);
@ -316,6 +422,14 @@ function ResourceIdentity({ resource = {} }) {
);
}
function PurchaseUser({ order }) {
const user = order.user || { userId: order.userId };
if (!user?.userId && !user?.displayUserId && !user?.username) {
return "-";
}
return <AdminUserIdentity openInAppUserDetail user={user} />;
}
function SaleSpec({ item }) {
return (
<div className={styles.stack}>
@ -342,3 +456,10 @@ function resourcePriceLabel(item = {}) {
}
return resourcePriceTypeLabels[item.priceType] || "-";
}
function coinPriceLabel(value) {
if (value === 0 || value) {
return `${Number(value).toLocaleString("zh-CN")}金币`;
}
return "-";
}

View File

@ -46,6 +46,8 @@ function emptyForm() {
function emptyHumanConfigForm() {
return {
candidateRoomMaxOnline: "5",
countryLimitEnabled: false,
countryRules: [],
enabled: false,
giftIds: [],
luckyComboMax: "3",
@ -66,6 +68,13 @@ function emptyHumanConfigForm() {
};
}
function emptyHumanCountryRule(countryOptions = [], existingRules = []) {
return {
countryCode: firstAvailableCountryCode(countryOptions, existingRules),
maxRoomCount: "1",
};
}
export function useRobotRoomsPage() {
const abilities = useRoomAbilities();
const { countryOptions, loadingCountries } = useCountryOptions();
@ -174,6 +183,16 @@ export function useRobotRoomsPage() {
setHumanConfigForm(humanConfigToForm(humanConfigState.config));
};
const openHumanCountryConfig = () => {
const form = humanConfigToForm(humanConfigState.config);
setActiveAction("human-config");
setHumanConfigForm({
...form,
countryLimitEnabled: true,
countryRules: form.countryRules.length ? form.countryRules : [emptyHumanCountryRule(countryOptions)],
});
};
const closeAction = () => {
setActiveAction("");
};
@ -232,6 +251,41 @@ export function useRobotRoomsPage() {
setHumanConfigForm((current) => ({ ...current, [key]: gifts.map(giftId).filter(Boolean) }));
};
const setHumanCountryLimitEnabled = (enabled) => {
setHumanConfigForm((current) => ({
...current,
countryLimitEnabled: enabled,
countryRules:
enabled && current.countryRules.length === 0
? [emptyHumanCountryRule(countryOptions, current.countryRules)]
: current.countryRules,
}));
};
const addHumanCountryRule = () => {
setHumanConfigForm((current) => ({
...current,
countryLimitEnabled: true,
countryRules: [...current.countryRules, emptyHumanCountryRule(countryOptions, current.countryRules)],
}));
};
const updateHumanCountryRule = (index, patch) => {
setHumanConfigForm((current) => ({
...current,
countryRules: current.countryRules.map((rule, currentIndex) =>
currentIndex === index ? { ...rule, ...patch } : rule,
),
}));
};
const removeHumanCountryRule = (index) => {
setHumanConfigForm((current) => ({
...current,
countryRules: current.countryRules.filter((_, currentIndex) => currentIndex !== index),
}));
};
const submitHumanConfig = async (event) => {
event.preventDefault();
await runAction("human-config", "真人房间机器人配置已保存", async () => {
@ -326,7 +380,9 @@ export function useRobotRoomsPage() {
loading,
loadingAction,
loadingRobotLocationOptions: loadingCountries || loadingRegions,
addHumanCountryRule,
openCreate,
openHumanCountryConfig,
openHumanConfig,
page,
reload,
@ -343,12 +399,15 @@ export function useRobotRoomsPage() {
robotCountryLabels: countryLabels,
robotRegionLabels: regionLabels,
setForm,
setHumanCountryLimitEnabled,
setHumanConfigForm,
setPage,
setRoomStatus,
status,
submitCreate,
submitHumanConfig,
updateHumanCountryRule,
removeHumanCountryRule,
};
}
@ -360,6 +419,11 @@ function humanConfigToForm(config = null) {
return {
...defaults,
candidateRoomMaxOnline: String(config.candidateRoomMaxOnline ?? defaults.candidateRoomMaxOnline),
countryLimitEnabled: Boolean(config.countryLimitEnabled),
countryRules: (config.countryRules || []).map((rule) => ({
countryCode: String(rule.countryCode || "").toUpperCase(),
maxRoomCount: String(rule.maxRoomCount ?? "1"),
})),
enabled: Boolean(config.enabled),
giftIds: (config.giftIds || []).map(String),
luckyComboMax: String(config.luckyComboMax ?? defaults.luckyComboMax),
@ -388,6 +452,12 @@ function humanConfigToForm(config = null) {
};
}
function firstAvailableCountryCode(countryOptions = [], existingRules = []) {
const used = new Set(existingRules.map((rule) => String(rule.countryCode || "").toUpperCase()).filter(Boolean));
const option = countryOptions.find((country) => country.value && !used.has(String(country.value).toUpperCase()));
return option?.value || "";
}
export function robotOptionLabel(robot) {
if (!robot) {
return "";

View File

@ -1,7 +1,9 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import TuneOutlined from "@mui/icons-material/TuneOutlined";
import Autocomplete from "@mui/material/Autocomplete";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { Button } from "@/shared/ui/Button.jsx";
@ -410,14 +412,24 @@ function HumanConfigPanel({ page }) {
]}
/>
{page.abilities.canRobotUpdate ? (
<Button
loading={page.humanConfigLoading}
startIcon={<TuneOutlined fontSize="small" />}
variant="secondary"
onClick={page.openHumanConfig}
>
配置
</Button>
<div className={styles.humanConfigActions}>
<Button
loading={page.humanConfigLoading}
startIcon={<TuneOutlined fontSize="small" />}
variant="secondary"
onClick={page.openHumanCountryConfig}
>
{config.countryLimitEnabled ? "国家配置" : "国家启用"}
</Button>
<Button
loading={page.humanConfigLoading}
startIcon={<TuneOutlined fontSize="small" />}
variant="secondary"
onClick={page.openHumanConfig}
>
配置
</Button>
</div>
) : null}
</section>
);
@ -446,6 +458,15 @@ function HumanConfigDialog({ page }) {
}
/>
</label>
<label className={styles.switchField}>
<AdminSwitch
checked={Boolean(page.humanConfigForm.countryLimitEnabled)}
checkedLabel="启用"
label="启用国家配置"
uncheckedLabel="关闭"
onChange={(_, checked) => page.setHumanCountryLimitEnabled(checked)}
/>
</label>
<TextField
label="真人房间低于人数"
required
@ -608,16 +629,77 @@ function HumanConfigDialog({ page }) {
value={page.selectedHumanGiftOptions.superLuckyGiftIds}
onChange={(options) => page.selectHumanGiftIds("superLuckyGiftIds", options)}
/>
{page.humanConfigForm.countryLimitEnabled ? <HumanCountryRules page={page} /> : null}
<section className={styles.robotAutoPoolNotice}>
<div className={styles.primaryText}>机器人池自动获取</div>
<div className={styles.meta}>
系统会读取所有启用的全站机器人并按机器人账号国家进入同国家真人房间
{page.humanConfigForm.countryLimitEnabled
? "国家配置启用后,只会读取已配置国家的机器人,并限制每个国家同时进入的真人房数量。"
: "系统会读取所有启用的全站机器人,并按机器人账号国家进入同国家真人房间。"}
</div>
</section>
</AdminFormDialog>
);
}
function HumanCountryRules({ page }) {
const rows = page.humanConfigForm.countryRules || [];
return (
<section className={styles.countryRulesSection}>
<div className={styles.countryRulesHeader}>
<div className={styles.primaryText}>国家进房配置</div>
<Button
startIcon={<AddOutlined fontSize="small" />}
variant="secondary"
onClick={page.addHumanCountryRule}
>
添加国家
</Button>
</div>
<div className={styles.countryRuleRows}>
{rows.map((rule, index) => (
<div className={styles.countryRuleRow} key={`${rule.countryCode || "country"}-${index}`}>
<TextField
label="国家"
required
select
size="small"
value={rule.countryCode || ""}
onChange={(event) =>
page.updateHumanCountryRule(index, { countryCode: event.target.value })
}
>
{countrySelectOptions(page.countryOptions, rule.countryCode).map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<TextField
label="进房数量"
required
size="small"
type="number"
value={rule.maxRoomCount}
onChange={(event) =>
page.updateHumanCountryRule(index, { maxRoomCount: event.target.value })
}
/>
<Button
startIcon={<DeleteOutlineOutlined fontSize="small" />}
variant="secondary"
onClick={() => page.removeHumanCountryRule(index)}
>
删除
</Button>
</div>
))}
{rows.length === 0 ? <div className={styles.meta}>当前没有国家配置</div> : null}
</div>
</section>
);
}
function GiftAutocomplete({ label, loading, onChange, options, required = false, value }) {
return (
<Autocomplete
@ -682,6 +764,21 @@ function RequiredLabel({ children }) {
);
}
function countrySelectOptions(options = [], currentValue = "") {
const current = String(currentValue || "").toUpperCase();
const merged = options.map((option) => ({
label: option.label,
value: option.value,
}));
if (current && !merged.some((option) => option.value === current)) {
merged.unshift({ label: current, value: current });
}
if (!merged.length) {
return current ? [{ label: current, value: current }] : [{ label: "暂无可用国家", value: "" }];
}
return merged;
}
function RobotRoomStatus({ page, room }) {
const checked = room.status === "active";
return (

View File

@ -64,6 +64,13 @@
background: var(--bg-card-strong);
}
.humanConfigActions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
}
.formGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -288,6 +295,47 @@
background: var(--bg-card-strong);
}
.countryRulesSection {
display: grid;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card-strong);
}
.countryRulesHeader {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.countryRuleRows {
display: grid;
gap: var(--space-2);
}
.countryRuleRow {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(140px, 0.6fr) auto;
align-items: center;
gap: var(--space-2);
}
@media (max-width: 720px) {
.countryRulesHeader,
.humanConfigActions {
align-items: stretch;
flex-direction: column;
}
.countryRuleRow {
grid-template-columns: 1fr;
}
}
.rowActions {
display: inline-flex;
align-items: center;

View File

@ -40,6 +40,8 @@ describe("human room robot config schema", () => {
test("preserves robot ids and parses enabled config", () => {
const payload = parseForm(humanRoomRobotConfigSchema, {
candidateRoomMaxOnline: "5",
countryLimitEnabled: true,
countryRules: [{ countryCode: "sa", maxRoomCount: "2" }],
enabled: true,
giftIds: ["84"],
luckyComboMax: "3",
@ -65,6 +67,38 @@ describe("human room robot config schema", () => {
expect(payload.normalGiftIntervalMinMs).toBe(1000);
expect(payload.normalGiftIntervalMaxMs).toBe(20000);
expect(payload.giftIds).toEqual(["84"]);
expect(payload.countryLimitEnabled).toBe(true);
expect(payload.countryRules).toEqual([{ countryCode: "SA", maxRoomCount: 2 }]);
});
test("rejects duplicated country rules", () => {
expect(() =>
parseForm(humanRoomRobotConfigSchema, {
candidateRoomMaxOnline: "5",
countryLimitEnabled: true,
countryRules: [
{ countryCode: "SA", maxRoomCount: "2" },
{ countryCode: "sa", maxRoomCount: "3" },
],
enabled: true,
giftIds: ["84"],
luckyComboMax: "3",
luckyComboMin: "1",
luckyGiftIds: ["28"],
luckyPauseMaxMs: "20000",
luckyPauseMinMs: "5000",
maxGiftSenders: "1",
normalGiftIntervalMaxMs: "20000",
normalGiftIntervalMinMs: "1000",
robotReplaceMaxMs: "180000",
robotReplaceMinMs: "60000",
robotStayMaxMs: "600000",
robotStayMinMs: "60000",
roomTargetMaxOnline: "10",
roomTargetMinOnline: "8",
superLuckyGiftIds: [],
}),
).toThrow("国家不能重复");
});
test("requires gift pools when enabled", () => {

View File

@ -7,6 +7,11 @@ const robotUserIdSchema = (message: string) =>
.union([z.string(), z.number()])
.transform((value) => String(value).trim())
.refine((value) => /^[1-9]\d*$/.test(value), message);
const countryCodeSchema = z
.string()
.trim()
.transform((value) => value.toUpperCase())
.refine((value) => /^[A-Z]{2,3}$/.test(value), "国家码不正确");
const pinTypeSchema = z
.string()
.trim()
@ -137,6 +142,15 @@ export const robotRoomCreateSchema = z
export const humanRoomRobotConfigSchema = z
.object({
candidateRoomMaxOnline: z.coerce.number().int().min(1, "真人房间筛选人数不正确"),
countryLimitEnabled: z.coerce.boolean().default(false),
countryRules: z
.array(
z.object({
countryCode: countryCodeSchema,
maxRoomCount: z.coerce.number().int().min(1, "国家进房数量不正确"),
}),
)
.default([]),
enabled: z.coerce.boolean().default(false),
giftIds: z.array(z.string().trim().min(1)).default([]),
luckyComboMax: z.coerce.number().int().min(1, "幸运礼物连击范围不正确"),
@ -193,6 +207,26 @@ export const humanRoomRobotConfigSchema = z
.refine((value) => !value.enabled || value.luckyGiftIds.length + value.superLuckyGiftIds.length > 0, {
message: "启用后请选择幸运礼物或超级幸运礼物合集",
path: ["luckyGiftIds"],
})
.superRefine((value, context) => {
if (value.enabled && value.countryLimitEnabled && value.countryRules.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "启用国家配置后至少选择一个国家",
path: ["countryRules"],
});
}
const seen = new Set<string>();
value.countryRules.forEach((rule, index) => {
if (seen.has(rule.countryCode)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "国家不能重复",
path: ["countryRules", index, "countryCode"],
});
}
seen.add(rule.countryCode);
});
});
export type RoomUpdateForm = z.infer<typeof roomUpdateSchema>;

View File

@ -145,6 +145,8 @@ export const API_OPERATIONS = {
listCoinSellerLedger: "listCoinSellerLedger",
listCoinSellers: "listCoinSellers",
listCountries: "listCountries",
listCPApplications: "listCPApplications",
listCPRelationships: "listCPRelationships",
listCumulativeRechargeRewardGrants: "listCumulativeRechargeRewardGrants",
listDiceRobots: "listDiceRobots",
listEmojiPackCategories: "listEmojiPackCategories",
@ -180,6 +182,7 @@ export const API_OPERATIONS = {
listResourceGroups: "listResourceGroups",
listResources: "listResources",
listResourceShopItems: "listResourceShopItems",
listResourceShopPurchaseOrders: "listResourceShopPurchaseOrders",
listRobotRooms: "listRobotRooms",
listRoles: "listRoles",
listRoomPins: "listRoomPins",
@ -1211,6 +1214,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,
@ -1456,6 +1473,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "resource-shop:view",
permissions: ["resource-shop:view"]
},
listResourceShopPurchaseOrders: {
method: "GET",
operationId: API_OPERATIONS.listResourceShopPurchaseOrders,
path: "/v1/admin/resource-shop/purchase-orders",
permission: "resource-shop:view",
permissions: ["resource-shop:view"]
},
listRobotRooms: {
method: "GET",
operationId: API_OPERATIONS.listRobotRooms,

View File

@ -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;
@ -1988,6 +2020,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/resource-shop/purchase-orders": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listResourceShopPurchaseOrders"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/resource-shop/items/{shop_item_id}/disable": {
parameters: {
query?: never;
@ -3186,6 +3234,9 @@ export interface components {
ApiResponseHostProfilePage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageHostProfile"];
};
ApiResponseHumanRoomRobotConfig: components["schemas"]["Envelope"] & {
data?: components["schemas"]["HumanRoomRobotConfig"];
};
ApiResponseLogPage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageLog"];
};
@ -3298,6 +3349,36 @@ export interface components {
updatedAtMs?: number;
userId: string;
};
HumanRoomRobotCountryRule: {
countryCode: string;
maxRoomCount: number;
};
HumanRoomRobotConfig: {
candidateRoomMaxOnline?: number;
countryLimitEnabled?: boolean;
countryRules?: components["schemas"]["HumanRoomRobotCountryRule"][];
enabled?: boolean;
giftIds?: string[];
luckyComboMax?: number;
luckyComboMin?: number;
luckyGiftIds?: string[];
luckyPauseMaxMs?: number;
luckyPauseMinMs?: number;
maxGiftSenders?: number;
normalGiftIntervalMaxMs?: number;
normalGiftIntervalMinMs?: number;
robotReplaceMaxMs?: number;
robotReplaceMinMs?: number;
robotStayMaxMs?: number;
robotStayMinMs?: number;
roomFullStopOnline?: number;
roomTargetMaxOnline?: number;
roomTargetMinOnline?: number;
superLuckyGiftIds?: string[];
updatedAtMs?: number;
updatedByAdminId?: number;
};
HumanRoomRobotConfigInput: components["schemas"]["HumanRoomRobotConfig"];
Log: {
[key: string]: unknown;
};
@ -3406,6 +3487,15 @@ export interface components {
};
};
/** @description OK */
HumanRoomRobotConfigResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseHumanRoomRobotConfig"];
};
};
/** @description OK */
LogPageResponse: {
headers: {
[name: string]: unknown;
@ -3733,6 +3823,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;
@ -5966,6 +6080,18 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
listResourceShopPurchaseOrders: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
disableResourceShopItem: {
parameters: {
query?: never;
@ -6339,7 +6465,7 @@ export interface operations {
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["HumanRoomRobotConfigResponse"];
};
};
updateHumanRoomRobotConfig: {
@ -6349,9 +6475,13 @@ export interface operations {
path?: never;
cookie?: never;
};
requestBody?: never;
requestBody?: {
content: {
"application/json": components["schemas"]["HumanRoomRobotConfigInput"];
};
};
responses: {
200: components["responses"]["EmptyResponse"];
200: components["responses"]["HumanRoomRobotConfigResponse"];
};
};
startRobotRoom: {

View File

@ -1035,6 +1035,8 @@ export interface RobotRoomPayload {
export interface HumanRoomRobotConfigDto {
candidateRoomMaxOnline?: number;
countryLimitEnabled?: boolean;
countryRules?: HumanRoomRobotCountryRuleDto[];
enabled?: boolean;
giftIds?: string[];
luckyComboMax?: number;
@ -1058,8 +1060,15 @@ export interface HumanRoomRobotConfigDto {
updatedByAdminId?: number;
}
export interface HumanRoomRobotCountryRuleDto {
countryCode: string;
maxRoomCount: number;
}
export interface HumanRoomRobotConfigPayload {
candidateRoomMaxOnline: number;
countryLimitEnabled: boolean;
countryRules: HumanRoomRobotCountryRuleDto[];
enabled: boolean;
giftIds: string[];
luckyComboMax: number;

View File

@ -0,0 +1,339 @@
export const minimumCorrectedProbabilityPercent = 0.0001;
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 = baselineProbabilities(enabledTiers, {
baseTiers,
baselineProbability: options.baselineProbability,
positiveTiers,
});
const floorWeights = floorWeightsFromProbabilities(probabilityByIndex);
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, floorWeights);
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 baselineProbabilities(enabledTiers, context) {
const probabilities = new Map();
const defaultFloor =
enabledTiers.length * minimumCorrectedProbabilityPercent <= 100
? minimumCorrectedProbabilityPercent
: 100 / enabledTiers.length;
for (const item of enabledTiers) {
const customFloor = context.baselineProbability?.(item.tier, item.index, context);
const floor = Math.max(defaultFloor, decimal(customFloor || 0));
probabilities.set(item.index, roundPercent(floor));
}
return sumProbabilities(probabilities) <= 100 ? probabilities : equalProbabilities(enabledTiers);
}
function floorWeightsFromProbabilities(probabilities) {
const weights = new Map();
for (const [index, probability] of probabilities.entries()) {
weights.set(index, percentToWeightPPM(probability));
}
return weights;
}
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, floorWeights) {
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);
for (let step = 0; step < 20_000 && currentRTPPPM !== targetRTPPPM; step += 1) {
const diff = targetRTPPPM - currentRTPPPM;
const move = bestExpectedRTPMove(weights, baseTiers, diff, floorWeights);
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, floorWeights) {
let best = null;
for (const donor of baseTiers) {
const donorWeight = weights.get(donor.index) || 0;
const floorWeight = floorWeights.get(donor.index) ?? percentToWeightPPM(minimumCorrectedProbabilityPercent);
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);
}