增加概率

This commit is contained in:
zhx 2026-06-18 20:26:00 +08:00
parent d876ff6985
commit 3b21bd1133
12 changed files with 534 additions and 28 deletions

View File

@ -3344,7 +3344,7 @@
"operationId": "getHumanRoomRobotConfig", "operationId": "getHumanRoomRobotConfig",
"responses": { "responses": {
"200": { "200": {
"$ref": "#/components/responses/EmptyResponse" "$ref": "#/components/responses/HumanRoomRobotConfigResponse"
} }
}, },
"x-permission": "room-robot:view", "x-permission": "room-robot:view",
@ -3352,9 +3352,18 @@
}, },
"put": { "put": {
"operationId": "updateHumanRoomRobotConfig", "operationId": "updateHumanRoomRobotConfig",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HumanRoomRobotConfigInput"
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"$ref": "#/components/responses/EmptyResponse" "$ref": "#/components/responses/HumanRoomRobotConfigResponse"
} }
}, },
"x-permission": "room-robot:update", "x-permission": "room-robot:update",
@ -4681,6 +4690,16 @@
} }
} }
}, },
"HumanRoomRobotConfigResponse": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseHumanRoomRobotConfig"
}
}
}
},
"LogPageResponse": { "LogPageResponse": {
"description": "OK", "description": "OK",
"content": { "content": {
@ -5037,6 +5056,21 @@
} }
] ]
}, },
"ApiResponseHumanRoomRobotConfig": {
"allOf": [
{
"$ref": "#/components/schemas/Envelope"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/HumanRoomRobotConfig"
}
}
}
]
},
"ApiResponseLogPage": { "ApiResponseLogPage": {
"allOf": [ "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": { "Log": {
"type": "object", "type": "object",
"additionalProperties": true "additionalProperties": true

View File

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

View File

@ -397,6 +397,8 @@ export function generateWheelTierProbabilities(form) {
// 转盘没有幸运礼物的倍率字段,这里把每个奖档的 RTP 价值换算成“相对单抽价格的倍率”。 // 转盘没有幸运礼物的倍率字段,这里把每个奖档的 RTP 价值换算成“相对单抽价格的倍率”。
// 道具/装扮不计入 RTP因此只拿公共公式保留的最小正概率全无 RTP 奖档时退化为均分,保证仍可发布配置。 // 道具/装扮不计入 RTP因此只拿公共公式保留的最小正概率全无 RTP 奖档时退化为均分,保证仍可发布配置。
const probabilityByIndex = correctRTPProbabilities(tiers, form.targetRTPPercent, { const probabilityByIndex = correctRTPProbabilities(tiers, form.targetRTPPercent, {
baselineProbability: (tier, index, context) =>
wheelTierBaselineProbability(tier, index, context, form.drawPriceCoins),
fallback: "equal", fallback: "equal",
multiplier: (tier) => wheelTierMultiplier(tier, form.drawPriceCoins), multiplier: (tier) => wheelTierMultiplier(tier, form.drawPriceCoins),
participatesInRTP: wheelTierParticipatesInRTP, participatesInRTP: wheelTierParticipatesInRTP,
@ -412,6 +414,18 @@ function wheelTierMultiplier(tier, drawPriceCoins) {
return wheelTierRTPValueCoins(tier) / drawPrice; 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) { function wheelTierRTPValueCoins(tier) {
if (tier.rewardType === "coin") { if (tier.rewardType === "coin") {
return decimal(tier.rewardCoins || tier.rtpValueCoins); return decimal(tier.rewardCoins || tier.rtpValueCoins);

View File

@ -107,9 +107,9 @@ describe("wheel prize probability generation", () => {
}); });
expect(probabilityPPM(result)).toBe(1_000_000); expect(probabilityPPM(result)).toBe(1_000_000);
expect(wheelExpectedRTP(result, 10000)).toBeCloseTo(90, 4); expect(wheelExpectedRTP(result, 10000)).toBeCloseTo(90, 3);
expect(result[2].probabilityPercent).toBe(0.01); expect(result[2].probabilityPercent).toBe(0.0001);
expect(result[3].probabilityPercent).toBe(0.01); expect(result[3].probabilityPercent).toBe(0.0001);
}); });
test("clamps to the closest reachable RTP when every reward is above the target multiplier", () => { test("clamps to the closest reachable RTP when every reward is above the target multiplier", () => {

View File

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

View File

@ -1,7 +1,9 @@
import AddOutlined from "@mui/icons-material/AddOutlined"; import AddOutlined from "@mui/icons-material/AddOutlined";
import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined"; import BedroomParentOutlined from "@mui/icons-material/BedroomParentOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import TuneOutlined from "@mui/icons-material/TuneOutlined"; import TuneOutlined from "@mui/icons-material/TuneOutlined";
import Autocomplete from "@mui/material/Autocomplete"; import Autocomplete from "@mui/material/Autocomplete";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
@ -410,14 +412,24 @@ function HumanConfigPanel({ page }) {
]} ]}
/> />
{page.abilities.canRobotUpdate ? ( {page.abilities.canRobotUpdate ? (
<Button <div className={styles.humanConfigActions}>
loading={page.humanConfigLoading} <Button
startIcon={<TuneOutlined fontSize="small" />} loading={page.humanConfigLoading}
variant="secondary" startIcon={<TuneOutlined fontSize="small" />}
onClick={page.openHumanConfig} variant="secondary"
> onClick={page.openHumanCountryConfig}
配置 >
</Button> {config.countryLimitEnabled ? "国家配置" : "国家启用"}
</Button>
<Button
loading={page.humanConfigLoading}
startIcon={<TuneOutlined fontSize="small" />}
variant="secondary"
onClick={page.openHumanConfig}
>
配置
</Button>
</div>
) : null} ) : null}
</section> </section>
); );
@ -446,6 +458,15 @@ function HumanConfigDialog({ page }) {
} }
/> />
</label> </label>
<label className={styles.switchField}>
<AdminSwitch
checked={Boolean(page.humanConfigForm.countryLimitEnabled)}
checkedLabel="启用"
label="启用国家配置"
uncheckedLabel="关闭"
onChange={(_, checked) => page.setHumanCountryLimitEnabled(checked)}
/>
</label>
<TextField <TextField
label="真人房间低于人数" label="真人房间低于人数"
required required
@ -608,16 +629,77 @@ function HumanConfigDialog({ page }) {
value={page.selectedHumanGiftOptions.superLuckyGiftIds} value={page.selectedHumanGiftOptions.superLuckyGiftIds}
onChange={(options) => page.selectHumanGiftIds("superLuckyGiftIds", options)} onChange={(options) => page.selectHumanGiftIds("superLuckyGiftIds", options)}
/> />
{page.humanConfigForm.countryLimitEnabled ? <HumanCountryRules page={page} /> : null}
<section className={styles.robotAutoPoolNotice}> <section className={styles.robotAutoPoolNotice}>
<div className={styles.primaryText}>机器人池自动获取</div> <div className={styles.primaryText}>机器人池自动获取</div>
<div className={styles.meta}> <div className={styles.meta}>
系统会读取所有启用的全站机器人并按机器人账号国家进入同国家真人房间 {page.humanConfigForm.countryLimitEnabled
? "国家配置启用后,只会读取已配置国家的机器人,并限制每个国家同时进入的真人房数量。"
: "系统会读取所有启用的全站机器人,并按机器人账号国家进入同国家真人房间。"}
</div> </div>
</section> </section>
</AdminFormDialog> </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 }) { function GiftAutocomplete({ label, loading, onChange, options, required = false, value }) {
return ( return (
<Autocomplete <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 }) { function RobotRoomStatus({ page, room }) {
const checked = room.status === "active"; const checked = room.status === "active";
return ( return (

View File

@ -64,6 +64,13 @@
background: var(--bg-card-strong); background: var(--bg-card-strong);
} }
.humanConfigActions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
}
.formGrid { .formGrid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@ -288,6 +295,47 @@
background: var(--bg-card-strong); 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 { .rowActions {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@ -40,6 +40,8 @@ describe("human room robot config schema", () => {
test("preserves robot ids and parses enabled config", () => { test("preserves robot ids and parses enabled config", () => {
const payload = parseForm(humanRoomRobotConfigSchema, { const payload = parseForm(humanRoomRobotConfigSchema, {
candidateRoomMaxOnline: "5", candidateRoomMaxOnline: "5",
countryLimitEnabled: true,
countryRules: [{ countryCode: "sa", maxRoomCount: "2" }],
enabled: true, enabled: true,
giftIds: ["84"], giftIds: ["84"],
luckyComboMax: "3", luckyComboMax: "3",
@ -65,6 +67,38 @@ describe("human room robot config schema", () => {
expect(payload.normalGiftIntervalMinMs).toBe(1000); expect(payload.normalGiftIntervalMinMs).toBe(1000);
expect(payload.normalGiftIntervalMaxMs).toBe(20000); expect(payload.normalGiftIntervalMaxMs).toBe(20000);
expect(payload.giftIds).toEqual(["84"]); 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", () => { test("requires gift pools when enabled", () => {

View File

@ -7,6 +7,11 @@ const robotUserIdSchema = (message: string) =>
.union([z.string(), z.number()]) .union([z.string(), z.number()])
.transform((value) => String(value).trim()) .transform((value) => String(value).trim())
.refine((value) => /^[1-9]\d*$/.test(value), message); .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 const pinTypeSchema = z
.string() .string()
.trim() .trim()
@ -137,6 +142,15 @@ export const robotRoomCreateSchema = z
export const humanRoomRobotConfigSchema = z export const humanRoomRobotConfigSchema = z
.object({ .object({
candidateRoomMaxOnline: z.coerce.number().int().min(1, "真人房间筛选人数不正确"), 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), enabled: z.coerce.boolean().default(false),
giftIds: z.array(z.string().trim().min(1)).default([]), giftIds: z.array(z.string().trim().min(1)).default([]),
luckyComboMax: z.coerce.number().int().min(1, "幸运礼物连击范围不正确"), 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, { .refine((value) => !value.enabled || value.luckyGiftIds.length + value.superLuckyGiftIds.length > 0, {
message: "启用后请选择幸运礼物或超级幸运礼物合集", message: "启用后请选择幸运礼物或超级幸运礼物合集",
path: ["luckyGiftIds"], 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>; export type RoomUpdateForm = z.infer<typeof roomUpdateSchema>;

View File

@ -3218,6 +3218,9 @@ export interface components {
ApiResponseHostProfilePage: components["schemas"]["Envelope"] & { ApiResponseHostProfilePage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageHostProfile"]; data?: components["schemas"]["ApiPageHostProfile"];
}; };
ApiResponseHumanRoomRobotConfig: components["schemas"]["Envelope"] & {
data?: components["schemas"]["HumanRoomRobotConfig"];
};
ApiResponseLogPage: components["schemas"]["Envelope"] & { ApiResponseLogPage: components["schemas"]["Envelope"] & {
data?: components["schemas"]["ApiPageLog"]; data?: components["schemas"]["ApiPageLog"];
}; };
@ -3330,6 +3333,36 @@ export interface components {
updatedAtMs?: number; updatedAtMs?: number;
userId: string; 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: { Log: {
[key: string]: unknown; [key: string]: unknown;
}; };
@ -3438,6 +3471,15 @@ export interface components {
}; };
}; };
/** @description OK */ /** @description OK */
HumanRoomRobotConfigResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseHumanRoomRobotConfig"];
};
};
/** @description OK */
LogPageResponse: { LogPageResponse: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@ -6395,7 +6437,7 @@ export interface operations {
}; };
requestBody?: never; requestBody?: never;
responses: { responses: {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["HumanRoomRobotConfigResponse"];
}; };
}; };
updateHumanRoomRobotConfig: { updateHumanRoomRobotConfig: {
@ -6405,9 +6447,13 @@ export interface operations {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
requestBody?: never; requestBody?: {
content: {
"application/json": components["schemas"]["HumanRoomRobotConfigInput"];
};
};
responses: { responses: {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["HumanRoomRobotConfigResponse"];
}; };
}; };
startRobotRoom: { startRobotRoom: {

View File

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

View File

@ -1,4 +1,4 @@
export const minimumCorrectedProbabilityPercent = 0.01; export const minimumCorrectedProbabilityPercent = 0.0001;
const ppmScale = 1_000_000; const ppmScale = 1_000_000;
@ -26,7 +26,12 @@ export function correctRTPProbabilities(tiers, targetRTPPercent, options = {}) {
return fallback === "equal" ? equalProbabilities(enabledTiers) : null; 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); const fixedProbability = sumProbabilities(probabilityByIndex);
if (fixedProbability >= 100) { if (fixedProbability >= 100) {
return probabilityByIndex; return probabilityByIndex;
@ -42,7 +47,7 @@ export function correctRTPProbabilities(tiers, targetRTPPercent, options = {}) {
addContributionProbabilities(probabilityByIndex, contributionPlan); addContributionProbabilities(probabilityByIndex, contributionPlan);
assignResidualProbability(probabilityByIndex, zeroTiers, positiveTiers); assignResidualProbability(probabilityByIndex, zeroTiers, positiveTiers);
alignExpectedRTPByWeightPPM(probabilityByIndex, baseTiers, targetRTP); alignExpectedRTPByWeightPPM(probabilityByIndex, baseTiers, targetRTP, floorWeights);
return probabilityByIndex; return probabilityByIndex;
} }
@ -117,16 +122,26 @@ function equalProbabilities(enabledTiers) {
return probabilities; return probabilities;
} }
function positiveBaselineProbabilities(enabledTiers) { function baselineProbabilities(enabledTiers, context) {
const probabilities = new Map(); const probabilities = new Map();
const floor = const defaultFloor =
enabledTiers.length * minimumCorrectedProbabilityPercent <= 100 enabledTiers.length * minimumCorrectedProbabilityPercent <= 100
? minimumCorrectedProbabilityPercent ? minimumCorrectedProbabilityPercent
: 100 / enabledTiers.length; : 100 / enabledTiers.length;
for (const item of enabledTiers) { 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)); 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) { 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 targetRTPPPM = percentToWeightPPM(targetRTP);
const weights = new Map(); const weights = new Map();
for (const [index, probability] of probabilities.entries()) { for (const [index, probability] of probabilities.entries()) {
@ -217,11 +232,10 @@ function alignExpectedRTPByWeightPPM(probabilities, baseTiers, targetRTP) {
} }
normalizeWeightTotal(weights, baseTiers[0]); normalizeWeightTotal(weights, baseTiers[0]);
let currentRTPPPM = expectedRTPPPMByWeights(weights, baseTiers); let currentRTPPPM = expectedRTPPPMByWeights(weights, baseTiers);
const floorWeight = percentToWeightPPM(minimumCorrectedProbabilityPercent);
for (let step = 0; step < 20_000 && currentRTPPPM !== targetRTPPPM; step += 1) { for (let step = 0; step < 20_000 && currentRTPPPM !== targetRTPPPM; step += 1) {
const diff = targetRTPPPM - currentRTPPPM; 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)) { if (!move || Math.abs(diff - move.delta) >= Math.abs(diff)) {
break; break;
} }
@ -249,10 +263,11 @@ function normalizeWeightTotal(weights, receiver) {
} }
} }
function bestExpectedRTPMove(weights, baseTiers, diff, floorWeight) { function bestExpectedRTPMove(weights, baseTiers, diff, floorWeights) {
let best = null; let best = null;
for (const donor of baseTiers) { for (const donor of baseTiers) {
const donorWeight = weights.get(donor.index) || 0; const donorWeight = weights.get(donor.index) || 0;
const floorWeight = floorWeights.get(donor.index) ?? percentToWeightPPM(minimumCorrectedProbabilityPercent);
if (donorWeight <= floorWeight) { if (donorWeight <= floorWeight) {
continue; continue;
} }