增加概率

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",
"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

View File

@ -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);
});

View File

@ -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);

View File

@ -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", () => {

View File

@ -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 "";

View File

@ -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 (

View File

@ -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;

View File

@ -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", () => {

View File

@ -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>;

View File

@ -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: {

View File

@ -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;

View File

@ -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;
}