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 ? (
- }
- variant="secondary"
- onClick={page.openHumanConfig}
- >
- 配置
-
+
+ }
+ variant="secondary"
+ onClick={page.openHumanCountryConfig}
+ >
+ {config.countryLimitEnabled ? "国家配置" : "国家启用"}
+
+ }
+ variant="secondary"
+ onClick={page.openHumanConfig}
+ >
+ 配置
+
+
) : 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 (
+
+
+
国家进房配置
+
}
+ variant="secondary"
+ onClick={page.addHumanCountryRule}
+ >
+ 添加国家
+
+
+
+ {rows.map((rule, index) => (
+
+
+ page.updateHumanCountryRule(index, { countryCode: event.target.value })
+ }
+ >
+ {countrySelectOptions(page.countryOptions, rule.countryCode).map((option) => (
+
+ ))}
+
+
+ page.updateHumanCountryRule(index, { maxRoomCount: event.target.value })
+ }
+ />
+ }
+ variant="secondary"
+ onClick={() => page.removeHumanCountryRule(index)}
+ >
+ 删除
+
+
+ ))}
+ {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;
}