增加概率
This commit is contained in:
parent
d876ff6985
commit
3b21bd1133
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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 "";
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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>;
|
||||
|
||||
52
src/shared/api/generated/schema.d.ts
vendored
52
src/shared/api/generated/schema.d.ts
vendored
@ -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: {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user