增加概率
This commit is contained in:
parent
d876ff6985
commit
3b21bd1133
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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 "";
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
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"] & {
|
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: {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user