From 3b21bd1133ecee26a9f4fe88e0f1f3b7dd8c9667 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 18 Jun 2026 20:26:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=A6=82=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 143 +++++++++++++++++- src/features/lucky-gift/configModel.test.js | 2 +- src/features/operations/hooks/useWheelPage.js | 14 ++ .../operations/hooks/useWheelPage.test.js | 6 +- src/features/rooms/hooks/useRobotRoomsPage.js | 70 +++++++++ src/features/rooms/pages/RoomRobotPage.jsx | 115 ++++++++++++-- src/features/rooms/rooms.module.css | 48 ++++++ src/features/rooms/schema.test.ts | 34 +++++ src/features/rooms/schema.ts | 34 +++++ src/shared/api/generated/schema.d.ts | 52 ++++++- src/shared/api/types.ts | 9 ++ src/shared/utils/rtpProbability.js | 35 +++-- 12 files changed, 534 insertions(+), 28 deletions(-) diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index ffc5bc3..1b6b419 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -3344,7 +3344,7 @@ "operationId": "getHumanRoomRobotConfig", "responses": { "200": { - "$ref": "#/components/responses/EmptyResponse" + "$ref": "#/components/responses/HumanRoomRobotConfigResponse" } }, "x-permission": "room-robot:view", @@ -3352,9 +3352,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", @@ -4681,6 +4690,16 @@ } } }, + "HumanRoomRobotConfigResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseHumanRoomRobotConfig" + } + } + } + }, "LogPageResponse": { "description": "OK", "content": { @@ -5037,6 +5056,21 @@ } ] }, + "ApiResponseHumanRoomRobotConfig": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/HumanRoomRobotConfig" + } + } + } + ] + }, "ApiResponseLogPage": { "allOf": [ { @@ -5450,6 +5484,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 diff --git a/src/features/lucky-gift/configModel.test.js b/src/features/lucky-gift/configModel.test.js index 1e108ea..f5b023b 100644 --- a/src/features/lucky-gift/configModel.test.js +++ b/src/features/lucky-gift/configModel.test.js @@ -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); }); diff --git a/src/features/operations/hooks/useWheelPage.js b/src/features/operations/hooks/useWheelPage.js index 47dd6df..e5d3be9 100644 --- a/src/features/operations/hooks/useWheelPage.js +++ b/src/features/operations/hooks/useWheelPage.js @@ -397,6 +397,8 @@ export function generateWheelTierProbabilities(form) { // 转盘没有幸运礼物的倍率字段,这里把每个奖档的 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, @@ -412,6 +414,18 @@ function wheelTierMultiplier(tier, drawPriceCoins) { return wheelTierRTPValueCoins(tier) / drawPrice; } +function wheelTierBaselineProbability(tier, index, context, drawPriceCoins) { + if (!wheelTierParticipatesInRTP(tier)) { + return 0; + } + 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 wheelTierRTPValueCoins(tier) { if (tier.rewardType === "coin") { return decimal(tier.rewardCoins || tier.rtpValueCoins); diff --git a/src/features/operations/hooks/useWheelPage.test.js b/src/features/operations/hooks/useWheelPage.test.js index cf75fdd..4d32692 100644 --- a/src/features/operations/hooks/useWheelPage.test.js +++ b/src/features/operations/hooks/useWheelPage.test.js @@ -107,9 +107,9 @@ describe("wheel prize probability generation", () => { }); expect(probabilityPPM(result)).toBe(1_000_000); - expect(wheelExpectedRTP(result, 10000)).toBeCloseTo(90, 4); - expect(result[2].probabilityPercent).toBe(0.01); - expect(result[3].probabilityPercent).toBe(0.01); + expect(wheelExpectedRTP(result, 10000)).toBeCloseTo(90, 3); + expect(result[2].probabilityPercent).toBe(0.0001); + expect(result[3].probabilityPercent).toBe(0.0001); }); test("clamps to the closest reachable RTP when every reward is above the target multiplier", () => { diff --git a/src/features/rooms/hooks/useRobotRoomsPage.js b/src/features/rooms/hooks/useRobotRoomsPage.js index 0cdb2c2..ac3ca34 100644 --- a/src/features/rooms/hooks/useRobotRoomsPage.js +++ b/src/features/rooms/hooks/useRobotRoomsPage.js @@ -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 ""; diff --git a/src/features/rooms/pages/RoomRobotPage.jsx b/src/features/rooms/pages/RoomRobotPage.jsx index 41655d3..8fe124d 100644 --- a/src/features/rooms/pages/RoomRobotPage.jsx +++ b/src/features/rooms/pages/RoomRobotPage.jsx @@ -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 ? ( - +
+ + +
) : null} ); @@ -446,6 +458,15 @@ function HumanConfigDialog({ page }) { } /> + page.selectHumanGiftIds("superLuckyGiftIds", options)} /> + {page.humanConfigForm.countryLimitEnabled ? : null}
机器人池自动获取
- 系统会读取所有启用的全站机器人,并按机器人账号国家进入同国家真人房间。 + {page.humanConfigForm.countryLimitEnabled + ? "国家配置启用后,只会读取已配置国家的机器人,并限制每个国家同时进入的真人房数量。" + : "系统会读取所有启用的全站机器人,并按机器人账号国家进入同国家真人房间。"}
); } +function HumanCountryRules({ page }) { + const rows = page.humanConfigForm.countryRules || []; + return ( +
+
+
国家进房配置
+ +
+
+ {rows.map((rule, index) => ( +
+ + page.updateHumanCountryRule(index, { countryCode: event.target.value }) + } + > + {countrySelectOptions(page.countryOptions, rule.countryCode).map((option) => ( + + {option.label} + + ))} + + + page.updateHumanCountryRule(index, { maxRoomCount: event.target.value }) + } + /> + +
+ ))} + {rows.length === 0 ?
当前没有国家配置
: null} +
+
+ ); +} + function GiftAutocomplete({ label, loading, onChange, options, required = false, value }) { return ( ({ + 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 ( diff --git a/src/features/rooms/rooms.module.css b/src/features/rooms/rooms.module.css index 188e14e..3f6d5fc 100644 --- a/src/features/rooms/rooms.module.css +++ b/src/features/rooms/rooms.module.css @@ -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; diff --git a/src/features/rooms/schema.test.ts b/src/features/rooms/schema.test.ts index b52cee0..729cc87 100644 --- a/src/features/rooms/schema.test.ts +++ b/src/features/rooms/schema.test.ts @@ -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", () => { diff --git a/src/features/rooms/schema.ts b/src/features/rooms/schema.ts index f967906..b7788b2 100644 --- a/src/features/rooms/schema.ts +++ b/src/features/rooms/schema.ts @@ -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(); + 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; diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 858985f..58e9093 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -3218,6 +3218,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"]; }; @@ -3330,6 +3333,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; }; @@ -3438,6 +3471,15 @@ export interface components { }; }; /** @description OK */ + HumanRoomRobotConfigResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseHumanRoomRobotConfig"]; + }; + }; + /** @description OK */ LogPageResponse: { headers: { [name: string]: unknown; @@ -6395,7 +6437,7 @@ export interface operations { }; requestBody?: never; responses: { - 200: components["responses"]["EmptyResponse"]; + 200: components["responses"]["HumanRoomRobotConfigResponse"]; }; }; updateHumanRoomRobotConfig: { @@ -6405,9 +6447,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: { diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 94a8a21..6977431 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -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; diff --git a/src/shared/utils/rtpProbability.js b/src/shared/utils/rtpProbability.js index 133663a..04bed1c 100644 --- a/src/shared/utils/rtpProbability.js +++ b/src/shared/utils/rtpProbability.js @@ -1,4 +1,4 @@ -export const minimumCorrectedProbabilityPercent = 0.01; +export const minimumCorrectedProbabilityPercent = 0.0001; const ppmScale = 1_000_000; @@ -26,7 +26,12 @@ export function correctRTPProbabilities(tiers, targetRTPPercent, options = {}) { return fallback === "equal" ? equalProbabilities(enabledTiers) : null; } - const probabilityByIndex = positiveBaselineProbabilities(enabledTiers); + const probabilityByIndex = baselineProbabilities(enabledTiers, { + baseTiers, + baselineProbability: options.baselineProbability, + positiveTiers, + }); + const floorWeights = floorWeightsFromProbabilities(probabilityByIndex); const fixedProbability = sumProbabilities(probabilityByIndex); if (fixedProbability >= 100) { return probabilityByIndex; @@ -42,7 +47,7 @@ export function correctRTPProbabilities(tiers, targetRTPPercent, options = {}) { addContributionProbabilities(probabilityByIndex, contributionPlan); assignResidualProbability(probabilityByIndex, zeroTiers, positiveTiers); - alignExpectedRTPByWeightPPM(probabilityByIndex, baseTiers, targetRTP); + alignExpectedRTPByWeightPPM(probabilityByIndex, baseTiers, targetRTP, floorWeights); return probabilityByIndex; } @@ -117,16 +122,26 @@ function equalProbabilities(enabledTiers) { return probabilities; } -function positiveBaselineProbabilities(enabledTiers) { +function baselineProbabilities(enabledTiers, context) { const probabilities = new Map(); - const floor = + 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 probabilities; + 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) { @@ -209,7 +224,7 @@ function normalizeProbabilityTotal(probabilities, receiver) { } } -function alignExpectedRTPByWeightPPM(probabilities, baseTiers, targetRTP) { +function alignExpectedRTPByWeightPPM(probabilities, baseTiers, targetRTP, floorWeights) { const targetRTPPPM = percentToWeightPPM(targetRTP); const weights = new Map(); for (const [index, probability] of probabilities.entries()) { @@ -217,11 +232,10 @@ function alignExpectedRTPByWeightPPM(probabilities, baseTiers, targetRTP) { } 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); + const move = bestExpectedRTPMove(weights, baseTiers, diff, floorWeights); if (!move || Math.abs(diff - move.delta) >= Math.abs(diff)) { break; } @@ -249,10 +263,11 @@ function normalizeWeightTotal(weights, receiver) { } } -function bestExpectedRTPMove(weights, baseTiers, diff, floorWeight) { +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; }