[色子游戏]奖励配置-新增按规则自动生成奖励配置
This commit is contained in:
53
saiadmin-artd/scripts/generate-dice-reward-index.ts
Normal file
53
saiadmin-artd/scripts/generate-dice-reward-index.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 命令行:按「当前盘面 grid_number 排布」与 T1/T2/T4/T5 约束生成 DiceRewardConfig 表 JSON(不含保存)。
|
||||||
|
* 用法:pnpm tsx scripts/generate-dice-reward-index.ts [t1Min] [t2Min] [t4Fixed] [t5Fixed]
|
||||||
|
* 默认:3 5 1 1(T4/T5 为顺、逆加权条数固定值)
|
||||||
|
*
|
||||||
|
* 生成逻辑见 src/views/plugin/dice/reward_config/utils/generateIndexByRules.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildRowsFromTiers,
|
||||||
|
computeBoardFrequencies,
|
||||||
|
DEFAULT_TIER_REAL_EV_STANDARDS,
|
||||||
|
generateTiers,
|
||||||
|
summarizeCounts
|
||||||
|
} from '../src/views/plugin/dice/reward_config/utils/generateIndexByRules'
|
||||||
|
|
||||||
|
const grids = [
|
||||||
|
20, 27, 24, 10, 5, 15, 8, 22, 30, 23, 16, 12, 13, 7, 17, 9, 21, 26, 6, 29, 19, 11, 25, 14, 28, 18
|
||||||
|
]
|
||||||
|
|
||||||
|
const args = process.argv.slice(2).map((x) => parseInt(x, 10))
|
||||||
|
const t1 = Number.isFinite(args[0]) ? args[0] : 3
|
||||||
|
const t2 = Number.isFinite(args[1]) ? args[1] : 5
|
||||||
|
const x4 = Number.isFinite(args[2]) ? args[2] : 1
|
||||||
|
const x5 = Number.isFinite(args[3]) ? args[3] : 1
|
||||||
|
|
||||||
|
const constraints = {
|
||||||
|
t1MinCw: t1,
|
||||||
|
t2MinCw: t2,
|
||||||
|
t4FixedCw: x4,
|
||||||
|
t5FixedCw: x5,
|
||||||
|
t1MinCcw: t1,
|
||||||
|
t2MinCcw: t2,
|
||||||
|
t4FixedCcw: x4,
|
||||||
|
t5FixedCcw: x5
|
||||||
|
}
|
||||||
|
|
||||||
|
const gen = generateTiers({ grids, constraints })
|
||||||
|
if (gen.ok === false) {
|
||||||
|
console.error(gen.message)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const board = computeBoardFrequencies(grids)
|
||||||
|
if (board === null) {
|
||||||
|
console.error('computeBoardFrequencies failed')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = buildRowsFromTiers(grids, gen.tiers, DEFAULT_TIER_REAL_EV_STANDARDS)
|
||||||
|
const sc = summarizeCounts(board, gen.tiers)
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ weighted: { cw: sc.cw, ccw: sc.ccw }, rows }, null, 2))
|
||||||
@@ -50,7 +50,50 @@
|
|||||||
"warnDupGrid": "Duplicate dice points in this table: {list}",
|
"warnDupGrid": "Duplicate dice points in this table: {list}",
|
||||||
"warnNoBigwinToSave": "No BIGWIN rows to save",
|
"warnNoBigwinToSave": "No BIGWIN rows to save",
|
||||||
"warnBigwinDupGrid": "Duplicate big-win points in this table: {list}",
|
"warnBigwinDupGrid": "Duplicate big-win points in this table: {list}",
|
||||||
"infoNoBigwin": "No BIGWIN rows. Set tier to BIGWIN in the Reward Index tab first."
|
"infoNoBigwin": "No BIGWIN rows. Set tier to BIGWIN in the Reward Index tab first.",
|
||||||
|
"btnRuleGenerate": "Generate by rules",
|
||||||
|
"ruleGenerateTitle": "Generate reward index by rules",
|
||||||
|
"ruleGenerateRules": "[Generation logic (same as Create Reward Reference)]\n• 26 cells ordered by id ascending are positions 0–25; each row’s grid_number is 5–30 and unique.\n• Roll D (5–30): start at the cell whose grid_number equals D (start_index); clockwise landing = (start position + D) mod 26; counter-clockwise = start − D (if negative, +26).\n• Each reference row’s “dice points” column is the roll D; tier / real_ev / display text come from the config at the landing id.\n\n[Leopard rolls]\nFor rolls 5, 10, 15, 20, 25, 30, clockwise and counter-clockwise landing tiers must NOT be T4 or T5 (avoid leopard roll + penalty / try again).\n\n[real_ev vs tier]\nreal < −100 → T4; −100 < real < 0 → T3; 0 < real < 100 → T2; 100 < real < 500 → T1; T5 “try again” real_ev=0. Set per-tier real_ev standards below; values are written into the config and can be edited later.\n\n[Inputs]\nT1/T2: minimum weighted counts. T4 and T5: fixed weighted counts — clockwise and counter-clockwise must each match the number you set (one value per tier, applied to both directions). real_ev standards: one value per tier. Generated T1–T4 use ui_text / ui_text_en = 100 + real_ev (same EN); T5 uses “try again” / “Once again”. Remarks still distinguish break-even vs small win where applicable.",
|
||||||
|
"ruleGenT1Row": "T1 (big prize)",
|
||||||
|
"ruleGenT2Row": "T2 (small win / break-even)",
|
||||||
|
"ruleGenT3RealEvOnly": "T3 (rake)",
|
||||||
|
"ruleGenT4Row": "T4 (penalty)",
|
||||||
|
"ruleGenT5Row": "T5 (try again)",
|
||||||
|
"ruleGenMinCount": "Min count",
|
||||||
|
"ruleGenFixedCount": "Fixed count (CW & CCW)",
|
||||||
|
"ruleGenRealEvStd": "real_ev standard",
|
||||||
|
"ruleGenRealEvEditHint": "After saving, you can still edit display text, EN, real_ev and remarks per row in the table above.",
|
||||||
|
"ruleGenInvalidT1RealEv": "T1 real_ev must satisfy 100 < value < 500",
|
||||||
|
"ruleGenInvalidT2RealEv": "T2 real_ev must satisfy 0 < value < 100",
|
||||||
|
"ruleGenInvalidT3RealEv": "T3 real_ev must satisfy -100 < value < 0",
|
||||||
|
"ruleGenInvalidT4RealEv": "T4 real_ev must satisfy value < -100",
|
||||||
|
"ruleGenInvalidT5RealEv": "T5 “try again” real_ev must be 0",
|
||||||
|
"ruleGenT1Min": "T1 min (CW & CCW)",
|
||||||
|
"ruleGenT2Min": "T2 min (CW & CCW)",
|
||||||
|
"ruleGenT4Max": "T4 fixed count (CW & CCW)",
|
||||||
|
"ruleGenT5Max": "T5 fixed count (CW & CCW)",
|
||||||
|
"ruleGenScopeHint": "T1/T2 are minimums; T4 and T5 are exact: clockwise and counter-clockwise weighted counts must each equal the fixed value.",
|
||||||
|
"ruleGenApply": "Generate and save",
|
||||||
|
"ruleGenNeedFullGrid": "Missing id 0–25 rows or incomplete grid_number; cannot generate",
|
||||||
|
"ruleGenFreqFail": "Cannot compute board frequencies; check grid_number",
|
||||||
|
"ruleGenUnknownId": "Unknown reward index id: {id}",
|
||||||
|
"ruleGenSuccess": "Generated and saved. Clockwise weighted: T1={cwT1} T2={cwT2} T4={cwT4} T5={cwT5}; counter-clockwise: T1={ccT1} T2={ccT2} T4={ccT4} T5={ccT5}",
|
||||||
|
"btnJsonImport": "JSON import",
|
||||||
|
"jsonImportTitle": "Reward index JSON import",
|
||||||
|
"jsonImportHint": "Current Reward Index rows (excluding BIGWIN) are filled below. Edit and submit; id must be 0–25, grid_number must be 5–30. Submit applies to the table and saves.",
|
||||||
|
"jsonImportParseFail": "Invalid JSON",
|
||||||
|
"jsonImportNotArray": "Root must be a JSON array",
|
||||||
|
"jsonImportItemInvalid": "Item {n} is not a valid object",
|
||||||
|
"jsonImportMissingField": "Item {n} is missing field: {field}",
|
||||||
|
"jsonImportIdRange": "id must be 0–25; item {n} has {v}",
|
||||||
|
"jsonImportGridRange": "grid_number must be 5–30; item {n} has {v}",
|
||||||
|
"jsonImportDupId": "Duplicate id in JSON: {list}",
|
||||||
|
"jsonImportDupGrid": "Duplicate grid_number in JSON: {list}",
|
||||||
|
"jsonImportFullIdSet": "For 26 rows, id must be exactly 0–25 once each",
|
||||||
|
"jsonImportFullGridSet": "For 26 rows, grid_number must be exactly 5–30 once each",
|
||||||
|
"jsonImportUnknownId": "Unknown id: {id} (export from the current list first)",
|
||||||
|
"jsonImportTierInvalid": "Invalid tier at item {n}",
|
||||||
|
"jsonImportEmpty": "Nothing to submit"
|
||||||
},
|
},
|
||||||
"weightRatio": {
|
"weightRatio": {
|
||||||
"title": "T1–T5 Weight Ratio (Clockwise / Counter-clockwise)",
|
"title": "T1–T5 Weight Ratio (Clockwise / Counter-clockwise)",
|
||||||
|
|||||||
@@ -50,7 +50,50 @@
|
|||||||
"warnDupGrid": "色子点数在本表内不能重复,重复的点数为:{list}",
|
"warnDupGrid": "色子点数在本表内不能重复,重复的点数为:{list}",
|
||||||
"warnNoBigwinToSave": "暂无 BIGWIN 档位配置可保存",
|
"warnNoBigwinToSave": "暂无 BIGWIN 档位配置可保存",
|
||||||
"warnBigwinDupGrid": "大奖权重本表内点数不能重复,重复的点数为:{list}",
|
"warnBigwinDupGrid": "大奖权重本表内点数不能重复,重复的点数为:{list}",
|
||||||
"infoNoBigwin": "暂无 BIGWIN 档位配置,请先在「奖励索引」中设置 tier 为 BIGWIN"
|
"infoNoBigwin": "暂无 BIGWIN 档位配置,请先在「奖励索引」中设置 tier 为 BIGWIN",
|
||||||
|
"btnRuleGenerate": "按规则生成",
|
||||||
|
"ruleGenerateTitle": "按规则生成奖励索引",
|
||||||
|
"ruleGenerateRules": "【生成逻辑(与创建奖励对照一致)】\n• 盘面 26 格按 id 升序为位置 0~25;每条配置的 grid_number 为 5~30 且不重复。\n• 摇取点数 D(5~30):起点为「grid_number=D」所在格位的 id(即 start_index),顺时针落点位置 = (起点位置 + D) mod 26,逆时针落点 = 起点位置 − D(若小于 0 则 +26)。\n• 对照表每条记录的「色子点数」列为摇取点数 D;档位、真实结算、显示文案取自落点格位对应 id 的配置。\n\n【豹子摇取点数】\n摇取点数为 5、10、15、20、25、30 时,其顺/逆时针落点档位不能为 T4、T5(避免对照表上出现豹子点数 + 惩罚/再来一次)。\n\n【real_ev 与 tier】\nreal < −100 → T4;−100 < real < 0 → T3;0 < real < 100 → T2;100 < real < 500 → T1;T5「再来一次」real_ev=0。下方可为各档位填写统一的 real_ev 标准,生成时写入配置;细则可稍后在表格中再改。\n\n【本弹窗输入】\n条数:T1/T2「不少于」;T4、T5「固定」——顺时针与逆时针的加权条数(每条摇取结果计一次)须分别恰好等于所填数值;T4 与 T5 分开填写。\nreal_ev 标准:同档位各格使用同一数值。生成时 T1~T4 的 ui_text / ui_text_en 均为「100+真实结算」;T5 固定为「再来一次」/「Once again」。备注仍区分完美回本/小赚等。",
|
||||||
|
"ruleGenT1Row": "T1 大奖",
|
||||||
|
"ruleGenT2Row": "T2 小赚/回本",
|
||||||
|
"ruleGenT3RealEvOnly": "T3 抽水",
|
||||||
|
"ruleGenT4Row": "T4 惩罚",
|
||||||
|
"ruleGenT5Row": "T5 再来一次",
|
||||||
|
"ruleGenMinCount": "最少条数",
|
||||||
|
"ruleGenFixedCount": "固定条数(顺/逆)",
|
||||||
|
"ruleGenRealEvStd": "real_ev 标准",
|
||||||
|
"ruleGenRealEvEditHint": "生成并保存后,仍可在本页表格中逐条修改显示文案、英文、真实结算与备注。",
|
||||||
|
"ruleGenInvalidT1RealEv": "T1 的 real_ev 须满足:100 < 值 < 500",
|
||||||
|
"ruleGenInvalidT2RealEv": "T2 的 real_ev 须满足:0 < 值 < 100",
|
||||||
|
"ruleGenInvalidT3RealEv": "T3 的 real_ev 须满足:-100 < 值 < 0",
|
||||||
|
"ruleGenInvalidT4RealEv": "T4 的 real_ev 须满足:值 < -100",
|
||||||
|
"ruleGenInvalidT5RealEv": "T5「再来一次」的 real_ev 须为 0",
|
||||||
|
"ruleGenT1Min": "T1 最少条数(顺/逆)",
|
||||||
|
"ruleGenT2Min": "T2 最少条数(顺/逆)",
|
||||||
|
"ruleGenT4Max": "T4 固定条数(顺/逆)",
|
||||||
|
"ruleGenT5Max": "T5 固定条数(顺/逆)",
|
||||||
|
"ruleGenScopeHint": "T1/T2 为「不少于」;T4、T5 为「恰好」:顺时针与逆时针加权条数须分别等于所填固定值。",
|
||||||
|
"ruleGenApply": "生成并保存",
|
||||||
|
"ruleGenNeedFullGrid": "当前列表缺少 id 0~25 的奖励索引行或色子点数不完整,无法生成",
|
||||||
|
"ruleGenFreqFail": "无法计算盘面频率,请检查 grid_number",
|
||||||
|
"ruleGenUnknownId": "不存在奖励索引 id:{id}",
|
||||||
|
"ruleGenSuccess": "已按规则生成并保存。顺时针加权:T1={cwT1} T2={cwT2} T4={cwT4} T5={cwT5};逆时针加权:T1={ccT1} T2={ccT2} T4={ccT4} T5={ccT5}",
|
||||||
|
"btnJsonImport": "JSON 导入",
|
||||||
|
"jsonImportTitle": "奖励索引 JSON 导入",
|
||||||
|
"jsonImportHint": "当前「奖励索引」表数据(不含 BIGWIN)已填入下方,可编辑后提交;奖励索引 id 须为 0~25,色子点数 grid_number 须为 5~30。提交后将写入表格并保存。",
|
||||||
|
"jsonImportParseFail": "JSON 解析失败,请检查格式",
|
||||||
|
"jsonImportNotArray": "JSON 根节点必须为数组",
|
||||||
|
"jsonImportItemInvalid": "第 {n} 项不是有效对象",
|
||||||
|
"jsonImportMissingField": "第 {n} 项缺少字段:{field}",
|
||||||
|
"jsonImportIdRange": "奖励索引 id 须为 0~25,第 {n} 项为 {v}",
|
||||||
|
"jsonImportGridRange": "色子点数 grid_number 须为 5~30,第 {n} 项为 {v}",
|
||||||
|
"jsonImportDupId": "JSON 内奖励索引 id 重复:{list}",
|
||||||
|
"jsonImportDupGrid": "JSON 内色子点数重复:{list}",
|
||||||
|
"jsonImportFullIdSet": "共 26 条时,奖励索引 id 必须且仅能各出现一次(0~25)",
|
||||||
|
"jsonImportFullGridSet": "共 26 条时,色子点数必须且仅能各出现一次(5~30)",
|
||||||
|
"jsonImportUnknownId": "不存在奖励索引 id:{id}(请从当前列表导出后编辑)",
|
||||||
|
"jsonImportTierInvalid": "第 {n} 项所属档位 tier 无效",
|
||||||
|
"jsonImportEmpty": "没有可提交的条目"
|
||||||
},
|
},
|
||||||
"weightRatio": {
|
"weightRatio": {
|
||||||
"title": "T1-T5 权重配比(顺时针/逆时针)",
|
"title": "T1-T5 权重配比(顺时针/逆时针)",
|
||||||
|
|||||||
@@ -21,6 +21,16 @@
|
|||||||
<ElTabPane :label="$t('page.configPage.tabIndex')" name="index">
|
<ElTabPane :label="$t('page.configPage.tabIndex')" name="index">
|
||||||
<div class="tab-panel">
|
<div class="tab-panel">
|
||||||
<div class="panel-tip">{{ $t('page.configPage.tipIndex') }}</div>
|
<div class="panel-tip">{{ $t('page.configPage.tipIndex') }}</div>
|
||||||
|
<div class="index-toolbar">
|
||||||
|
<ElButton
|
||||||
|
v-permission="'dice:reward_config:index:batchUpdate'"
|
||||||
|
type="default"
|
||||||
|
@click="openRuleGenerateDialog"
|
||||||
|
v-ripple
|
||||||
|
>
|
||||||
|
{{ $t('page.configPage.btnRuleGenerate') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
<div class="table-scroll-wrap">
|
<div class="table-scroll-wrap">
|
||||||
<ElTable
|
<ElTable
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
@@ -212,16 +222,194 @@
|
|||||||
</ElTabPane>
|
</ElTabPane>
|
||||||
</ElTabs>
|
</ElTabs>
|
||||||
</ElCard>
|
</ElCard>
|
||||||
|
|
||||||
|
<ElDialog
|
||||||
|
v-model="ruleGenerateDialogVisible"
|
||||||
|
:title="$t('page.configPage.ruleGenerateTitle')"
|
||||||
|
:width="ruleGenDialogWidth"
|
||||||
|
:fullscreen="ruleGenFullscreen"
|
||||||
|
align-center
|
||||||
|
destroy-on-close
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
class="rule-generate-dialog"
|
||||||
|
>
|
||||||
|
<div class="rule-generate-rules">{{ $t('page.configPage.ruleGenerateRules') }}</div>
|
||||||
|
<ElForm
|
||||||
|
:label-position="ruleGenFormLabelPosition"
|
||||||
|
:label-width="ruleGenFormLabelWidth"
|
||||||
|
class="rule-generate-form"
|
||||||
|
@submit.prevent
|
||||||
|
>
|
||||||
|
<ElFormItem :label="$t('page.configPage.ruleGenT1Row')">
|
||||||
|
<div class="rule-gen-row">
|
||||||
|
<div class="rule-gen-cell">
|
||||||
|
<span class="rule-gen-hint">{{ $t('page.configPage.ruleGenMinCount') }}</span>
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="ruleGenT1Min"
|
||||||
|
class="rule-gen-input-num"
|
||||||
|
:min="0"
|
||||||
|
:max="26"
|
||||||
|
:step="1"
|
||||||
|
:controls="ruleGenInputControls"
|
||||||
|
:size="ruleGenInputSize"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="rule-gen-cell">
|
||||||
|
<span class="rule-gen-hint">{{ $t('page.configPage.ruleGenRealEvStd') }}</span>
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="ruleGenT1RealEv"
|
||||||
|
class="rule-gen-input-num"
|
||||||
|
:step="1"
|
||||||
|
:controls="ruleGenInputControls"
|
||||||
|
:size="ruleGenInputSize"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('page.configPage.ruleGenT2Row')">
|
||||||
|
<div class="rule-gen-row">
|
||||||
|
<div class="rule-gen-cell">
|
||||||
|
<span class="rule-gen-hint">{{ $t('page.configPage.ruleGenMinCount') }}</span>
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="ruleGenT2Min"
|
||||||
|
class="rule-gen-input-num"
|
||||||
|
:min="0"
|
||||||
|
:max="26"
|
||||||
|
:step="1"
|
||||||
|
:controls="ruleGenInputControls"
|
||||||
|
:size="ruleGenInputSize"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="rule-gen-cell">
|
||||||
|
<span class="rule-gen-hint">{{ $t('page.configPage.ruleGenRealEvStd') }}</span>
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="ruleGenT2RealEv"
|
||||||
|
class="rule-gen-input-num"
|
||||||
|
:step="1"
|
||||||
|
:controls="ruleGenInputControls"
|
||||||
|
:size="ruleGenInputSize"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('page.configPage.ruleGenT3RealEvOnly')">
|
||||||
|
<div class="rule-gen-row">
|
||||||
|
<div class="rule-gen-cell">
|
||||||
|
<span class="rule-gen-hint">{{ $t('page.configPage.ruleGenRealEvStd') }}</span>
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="ruleGenT3RealEv"
|
||||||
|
class="rule-gen-input-num"
|
||||||
|
:step="1"
|
||||||
|
:controls="ruleGenInputControls"
|
||||||
|
:size="ruleGenInputSize"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('page.configPage.ruleGenT4Row')">
|
||||||
|
<div class="rule-gen-row">
|
||||||
|
<div class="rule-gen-cell">
|
||||||
|
<span class="rule-gen-hint">{{ $t('page.configPage.ruleGenFixedCount') }}</span>
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="ruleGenT4Fixed"
|
||||||
|
class="rule-gen-input-num"
|
||||||
|
:min="0"
|
||||||
|
:max="26"
|
||||||
|
:step="1"
|
||||||
|
:controls="ruleGenInputControls"
|
||||||
|
:size="ruleGenInputSize"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="rule-gen-cell">
|
||||||
|
<span class="rule-gen-hint">{{ $t('page.configPage.ruleGenRealEvStd') }}</span>
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="ruleGenT4RealEv"
|
||||||
|
class="rule-gen-input-num"
|
||||||
|
:step="1"
|
||||||
|
:controls="ruleGenInputControls"
|
||||||
|
:size="ruleGenInputSize"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('page.configPage.ruleGenT5Row')">
|
||||||
|
<div class="rule-gen-row">
|
||||||
|
<div class="rule-gen-cell">
|
||||||
|
<span class="rule-gen-hint">{{ $t('page.configPage.ruleGenFixedCount') }}</span>
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="ruleGenT5Fixed"
|
||||||
|
class="rule-gen-input-num"
|
||||||
|
:min="0"
|
||||||
|
:max="26"
|
||||||
|
:step="1"
|
||||||
|
:controls="ruleGenInputControls"
|
||||||
|
:size="ruleGenInputSize"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="rule-gen-cell">
|
||||||
|
<span class="rule-gen-hint">{{ $t('page.configPage.ruleGenRealEvStd') }}</span>
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="ruleGenT5RealEv"
|
||||||
|
class="rule-gen-input-num"
|
||||||
|
:disabled="true"
|
||||||
|
:step="1"
|
||||||
|
:controls="ruleGenInputControls"
|
||||||
|
:size="ruleGenInputSize"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<p class="rule-generate-scope">{{ $t('page.configPage.ruleGenScopeHint') }}</p>
|
||||||
|
<p class="rule-generate-scope">{{ $t('page.configPage.ruleGenRealEvEditHint') }}</p>
|
||||||
|
</ElForm>
|
||||||
|
<template #footer>
|
||||||
|
<div class="rule-gen-footer-btns">
|
||||||
|
<ElButton @click="ruleGenerateDialogVisible = false">{{ $t('common.cancel') }}</ElButton>
|
||||||
|
<ElButton type="primary" :loading="ruleGenSubmitting" @click="handleRuleGenerateApply">
|
||||||
|
{{ $t('page.configPage.ruleGenApply') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useWindowSize } from '@vueuse/core'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import api from '../../api/reward_config/index'
|
import api from '../../api/reward_config/index'
|
||||||
|
import {
|
||||||
|
buildRowsFromTiers,
|
||||||
|
computeBoardFrequencies,
|
||||||
|
DEFAULT_TIER_REAL_EV_STANDARDS,
|
||||||
|
generateTiers,
|
||||||
|
summarizeCounts,
|
||||||
|
validateTierRealEvStandards
|
||||||
|
} from '../utils/generateIndexByRules'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const { width: viewportWidth } = useWindowSize()
|
||||||
|
/** 窄屏:单列、标签置顶、全屏弹窗 */
|
||||||
|
const isRuleGenMobile = computed(() => viewportWidth.value < 640)
|
||||||
|
const ruleGenDialogWidth = computed(() => (isRuleGenMobile.value ? '100%' : 'min(880px, 92vw)'))
|
||||||
|
const ruleGenFullscreen = computed(() => isRuleGenMobile.value)
|
||||||
|
const ruleGenFormLabelPosition = computed(() => (isRuleGenMobile.value ? 'top' : 'right'))
|
||||||
|
const ruleGenFormLabelWidth = computed(() => (isRuleGenMobile.value ? undefined : '168px'))
|
||||||
|
/** 移动端隐藏步进按钮,避免误触;用系统数字键盘输入 */
|
||||||
|
const ruleGenInputControls = computed(() => !isRuleGenMobile.value)
|
||||||
|
const ruleGenInputSize = computed(() => (isRuleGenMobile.value ? 'large' : 'default'))
|
||||||
|
|
||||||
/** 第一页:奖励索引行(来自 DiceRewardConfig 表) */
|
/** 第一页:奖励索引行(来自 DiceRewardConfig 表) */
|
||||||
interface IndexRow {
|
interface IndexRow {
|
||||||
id: number
|
id: number
|
||||||
@@ -239,6 +427,31 @@
|
|||||||
const savingIndex = ref(false)
|
const savingIndex = ref(false)
|
||||||
const savingBigwin = ref(false)
|
const savingBigwin = ref(false)
|
||||||
const createRewardLoading = ref(false)
|
const createRewardLoading = ref(false)
|
||||||
|
const ruleGenerateDialogVisible = ref(false)
|
||||||
|
const ruleGenSubmitting = ref(false)
|
||||||
|
const ruleGenT1Min = ref(3)
|
||||||
|
const ruleGenT2Min = ref(5)
|
||||||
|
const ruleGenT4Fixed = ref(1)
|
||||||
|
const ruleGenT5Fixed = ref(1)
|
||||||
|
const ruleGenT1RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T1)
|
||||||
|
const ruleGenT2RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T2)
|
||||||
|
const ruleGenT3RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T3)
|
||||||
|
const ruleGenT4RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T4)
|
||||||
|
const ruleGenT5RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T5)
|
||||||
|
|
||||||
|
/** 奖励索引 id 与后端 DiceRewardConfigLogic 一致:0~25 */
|
||||||
|
const REWARD_INDEX_MIN = 0
|
||||||
|
const REWARD_INDEX_MAX = 25
|
||||||
|
const ALLOWED_INDEX_TIERS = ['T1', 'T2', 'T3', 'T4', 'T5', 'BIGWIN'] as const
|
||||||
|
|
||||||
|
function isAllowedIndexTier(s: string): boolean {
|
||||||
|
for (let i = 0; i < ALLOWED_INDEX_TIERS.length; i++) {
|
||||||
|
if (ALLOWED_INDEX_TIERS[i] === s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/** 第一页数据(来自 api.list,即 DiceRewardConfig 表) */
|
/** 第一页数据(来自 api.list,即 DiceRewardConfig 表) */
|
||||||
const indexRows = ref<IndexRow[]>([])
|
const indexRows = ref<IndexRow[]>([])
|
||||||
@@ -349,8 +562,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 奖励索引表单校验:仅对本表内的行(不含 BIGWIN)校验,点数 5~30 且本批内不重复 */
|
/** 奖励索引表单校验:仅对本表内的行(不含 BIGWIN)校验,点数 5~30 且本批内不重复 */
|
||||||
function validateIndexFormForSave(): string | null {
|
function validateIndexFormForSaveRows(rows: IndexRow[]): string | null {
|
||||||
const toSave = indexRows.value.filter((r) => r.tier !== 'BIGWIN')
|
const toSave = rows.filter((r) => r.tier !== 'BIGWIN')
|
||||||
if (toSave.length === 0) {
|
if (toSave.length === 0) {
|
||||||
return t('page.configPage.warnNoIndexToSave')
|
return t('page.configPage.warnNoIndexToSave')
|
||||||
}
|
}
|
||||||
@@ -370,6 +583,171 @@
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateIndexFormForSave(): string | null {
|
||||||
|
return validateIndexFormForSaveRows(indexRows.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从当前表提取 id 0~25 的色子点数(不含 BIGWIN),用于按规则生成 */
|
||||||
|
function extractGrids26(): number[] | null {
|
||||||
|
const map = new Map<number, number>()
|
||||||
|
for (const r of indexRows.value) {
|
||||||
|
if (r.tier === 'BIGWIN') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (r.id >= REWARD_INDEX_MIN && r.id <= REWARD_INDEX_MAX) {
|
||||||
|
map.set(r.id, Number(r.grid_number))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: number[] = []
|
||||||
|
for (let id = REWARD_INDEX_MIN; id <= REWARD_INDEX_MAX; id++) {
|
||||||
|
if (!map.has(id)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const gn = map.get(id)
|
||||||
|
if (gn === undefined || Number.isNaN(gn)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
out.push(gn)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRuleGenerateDialog() {
|
||||||
|
ruleGenerateDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGeneratedRowsToIndex(
|
||||||
|
items: Array<{
|
||||||
|
id: number
|
||||||
|
grid_number: number
|
||||||
|
ui_text: string
|
||||||
|
ui_text_en: string
|
||||||
|
real_ev: number
|
||||||
|
tier: string
|
||||||
|
remark: string
|
||||||
|
}>
|
||||||
|
): string | null {
|
||||||
|
const next = indexRows.value.map((r) => ({ ...r }))
|
||||||
|
for (const item of items) {
|
||||||
|
const row = next.find((x) => x.id === item.id)
|
||||||
|
if (row === undefined) {
|
||||||
|
return t('page.configPage.ruleGenUnknownId', { id: item.id })
|
||||||
|
}
|
||||||
|
row.grid_number = item.grid_number
|
||||||
|
row.ui_text = item.ui_text
|
||||||
|
row.ui_text_en = item.ui_text_en
|
||||||
|
row.real_ev = item.real_ev
|
||||||
|
row.tier = item.tier
|
||||||
|
row.remark = item.remark
|
||||||
|
}
|
||||||
|
const err = validateIndexFormForSaveRows(next)
|
||||||
|
if (err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
indexRows.value = next
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRuleGenerateApply() {
|
||||||
|
const grids = extractGrids26()
|
||||||
|
if (grids === null) {
|
||||||
|
ElMessage.warning(t('page.configPage.ruleGenNeedFullGrid'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const t1 = Math.max(0, Math.min(26, Math.floor(Number(ruleGenT1Min.value))))
|
||||||
|
const t2 = Math.max(0, Math.min(26, Math.floor(Number(ruleGenT2Min.value))))
|
||||||
|
const x4 = Math.max(0, Math.min(26, Math.floor(Number(ruleGenT4Fixed.value))))
|
||||||
|
const x5 = Math.max(0, Math.min(26, Math.floor(Number(ruleGenT5Fixed.value))))
|
||||||
|
const standards = {
|
||||||
|
T1: Number(ruleGenT1RealEv.value),
|
||||||
|
T2: Number(ruleGenT2RealEv.value),
|
||||||
|
T3: Number(ruleGenT3RealEv.value),
|
||||||
|
T4: Number(ruleGenT4RealEv.value),
|
||||||
|
T5: Number(ruleGenT5RealEv.value)
|
||||||
|
}
|
||||||
|
const invalidKey = validateTierRealEvStandards(standards)
|
||||||
|
if (invalidKey !== null) {
|
||||||
|
ElMessage.warning(t(`page.configPage.${invalidKey}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const constraints = {
|
||||||
|
t1MinCw: t1,
|
||||||
|
t2MinCw: t2,
|
||||||
|
t4FixedCw: x4,
|
||||||
|
t5FixedCw: x5,
|
||||||
|
t1MinCcw: t1,
|
||||||
|
t2MinCcw: t2,
|
||||||
|
t4FixedCcw: x4,
|
||||||
|
t5FixedCcw: x5
|
||||||
|
}
|
||||||
|
const gen = generateTiers({ grids, constraints })
|
||||||
|
if (!gen.ok) {
|
||||||
|
ElMessage.warning(gen.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const built = buildRowsFromTiers(grids, gen.tiers, standards)
|
||||||
|
const mergeErr = applyGeneratedRowsToIndex(built)
|
||||||
|
if (mergeErr) {
|
||||||
|
ElMessage.warning(mergeErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const board = computeBoardFrequencies(grids)
|
||||||
|
if (board === null) {
|
||||||
|
ElMessage.warning(t('page.configPage.ruleGenFreqFail'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sc = summarizeCounts(board, gen.tiers)
|
||||||
|
ruleGenSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const indexPayload = built.map(
|
||||||
|
(r: {
|
||||||
|
id: number
|
||||||
|
grid_number: number
|
||||||
|
ui_text: string
|
||||||
|
ui_text_en: string
|
||||||
|
real_ev: number
|
||||||
|
tier: string
|
||||||
|
remark: string
|
||||||
|
}) => ({
|
||||||
|
id: r.id,
|
||||||
|
grid_number: r.grid_number,
|
||||||
|
ui_text: r.ui_text,
|
||||||
|
ui_text_en: r.ui_text_en,
|
||||||
|
real_ev: r.real_ev,
|
||||||
|
tier: r.tier,
|
||||||
|
remark: r.remark
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await api.batchUpdate(indexPayload)
|
||||||
|
ElMessage.success(
|
||||||
|
t('page.configPage.ruleGenSuccess', {
|
||||||
|
cwT1: sc.cw.T1,
|
||||||
|
cwT2: sc.cw.T2,
|
||||||
|
cwT4: sc.cw.T4,
|
||||||
|
cwT5: sc.cw.T5,
|
||||||
|
ccT1: sc.ccw.T1,
|
||||||
|
ccT2: sc.ccw.T2,
|
||||||
|
ccT4: sc.ccw.T4,
|
||||||
|
ccT5: sc.ccw.T5
|
||||||
|
})
|
||||||
|
)
|
||||||
|
indexRowsSnapshot = indexRows.value.map((r) => ({ ...r }))
|
||||||
|
ruleGenerateDialogVisible.value = false
|
||||||
|
} catch (e: unknown) {
|
||||||
|
let msg = ''
|
||||||
|
if (e !== null && typeof e === 'object' && 'message' in e) {
|
||||||
|
const m = Reflect.get(e, 'message')
|
||||||
|
if (typeof m === 'string') {
|
||||||
|
msg = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElMessage.error(msg || t('page.configPage.saveFail'))
|
||||||
|
loadIndexList()
|
||||||
|
} finally {
|
||||||
|
ruleGenSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 奖励索引表单:仅提交本表数据(T1~T5),不包含大奖权重 */
|
/** 奖励索引表单:仅提交本表数据(T1~T5),不包含大奖权重 */
|
||||||
async function handleSaveIndex() {
|
async function handleSaveIndex() {
|
||||||
const err = validateIndexFormForSave()
|
const err = validateIndexFormForSave()
|
||||||
@@ -551,6 +929,141 @@
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.index-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.rule-generate-dialog {
|
||||||
|
:deep(.el-dialog__header) {
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
:deep(.el-dialog__body) {
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
}
|
||||||
|
:deep(.el-dialog__footer) {
|
||||||
|
padding: 12px 16px calc(16px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
.rule-generate-rules {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 1.65;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
max-height: min(260px, 38vh);
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.rule-generate-form {
|
||||||
|
margin-top: 4px;
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
:deep(.el-form-item__label) {
|
||||||
|
line-height: 1.4;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rule-generate-scope {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin: 8px 0 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.rule-gen-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.rule-gen-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1 1 200px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.rule-gen-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.rule-gen-input-num {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.rule-gen-input-num :deep(.el-input__wrapper) {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
.rule-gen-footer-btns {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.rule-generate-dialog {
|
||||||
|
:deep(.el-dialog) {
|
||||||
|
margin: 0;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
:deep(.el-dialog.is-fullscreen) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
:deep(.el-dialog.is-fullscreen .el-dialog__body) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.rule-generate-rules {
|
||||||
|
max-height: min(200px, 32vh);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.rule-gen-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.rule-gen-cell {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.rule-gen-hint {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.rule-gen-input-num {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.rule-gen-input-num :deep(.el-input__wrapper) {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.rule-gen-footer-btns {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: stretch;
|
||||||
|
.el-button {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.config-table {
|
.config-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
.full-width {
|
.full-width {
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* 按与后端 DiceRewardLogic 一致的环形规则,为盘面 26 格(id 0~25)求档位 tier,
|
||||||
|
* 并生成 real_ev / ui 等字段。
|
||||||
|
*
|
||||||
|
* 摇取点数为 5~30:起点为 grid_number==摇取点数 的格位下标,顺时针落点 (start+摇取)%26,
|
||||||
|
* 逆时针落点 start-摇取(<0 则 +26)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type IndexTier = 'T1' | 'T2' | 'T3' | 'T4' | 'T5'
|
||||||
|
|
||||||
|
/** 豹子摇取点数:这些摇取下落点不得为 T4/T5(与 dice_reward 表中「点数」列一致) */
|
||||||
|
export const LEOPARD_ROLLS: readonly number[] = [5, 10, 15, 20, 25, 30]
|
||||||
|
|
||||||
|
const BOARD_SIZE = 26
|
||||||
|
const GRID_MIN = 5
|
||||||
|
const GRID_MAX = 30
|
||||||
|
|
||||||
|
const T_ALL: IndexTier[] = ['T1', 'T2', 'T3', 'T4', 'T5']
|
||||||
|
const T_NO_LEOPARD: IndexTier[] = ['T1', 'T2', 'T3']
|
||||||
|
export interface BoardFrequencies {
|
||||||
|
grids: number[]
|
||||||
|
freqCw: number[]
|
||||||
|
freqCcw: number[]
|
||||||
|
leopardLandCw: Set<number>
|
||||||
|
leopardLandCcw: Set<number>
|
||||||
|
leopardLandUnion: Set<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 条数约束:T1/T2 为「不少于」;T4/T5 为「恰好」(加权条数,与 dice_reward 中顺/逆各 26 条摇取结果一致)
|
||||||
|
*/
|
||||||
|
export interface TierCountConstraints {
|
||||||
|
t1MinCw: number
|
||||||
|
t2MinCw: number
|
||||||
|
/** 顺时针方向 T4 加权条数固定为该值 */
|
||||||
|
t4FixedCw: number
|
||||||
|
/** 顺时针方向 T5 加权条数固定为该值 */
|
||||||
|
t5FixedCw: number
|
||||||
|
t1MinCcw: number
|
||||||
|
t2MinCcw: number
|
||||||
|
/** 逆时针方向 T4 加权条数固定为该值 */
|
||||||
|
t4FixedCcw: number
|
||||||
|
/** 逆时针方向 T5 加权条数固定为该值 */
|
||||||
|
t5FixedCcw: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 各档位统一 real_ev 标准(生成 DiceRewardConfig 时使用;细则可再到表格里改) */
|
||||||
|
export interface TierRealEvStandards {
|
||||||
|
T1: number
|
||||||
|
T2: number
|
||||||
|
T3: number
|
||||||
|
T4: number
|
||||||
|
T5: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认标准(与常见业务约定一致:100<T1<500,等) */
|
||||||
|
export const DEFAULT_TIER_REAL_EV_STANDARDS: TierRealEvStandards = {
|
||||||
|
T1: 400,
|
||||||
|
T2: 50,
|
||||||
|
T3: -80,
|
||||||
|
T4: -140,
|
||||||
|
T5: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验档位与 real_ev 区间是否一致;通过返回 null,否则返回 i18n 键名(不含 page.configPage. 前缀)
|
||||||
|
*/
|
||||||
|
export function validateTierRealEvStandards(s: TierRealEvStandards): string | null {
|
||||||
|
if (!Number.isFinite(s.T1) || !(s.T1 > 100 && s.T1 < 500)) {
|
||||||
|
return 'ruleGenInvalidT1RealEv'
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(s.T2) || !(s.T2 > 0 && s.T2 < 100)) {
|
||||||
|
return 'ruleGenInvalidT2RealEv'
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(s.T3) || !(-100 < s.T3 && s.T3 < 0)) {
|
||||||
|
return 'ruleGenInvalidT3RealEv'
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(s.T4) || !(s.T4 < -100)) {
|
||||||
|
return 'ruleGenInvalidT4RealEv'
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(s.T5) || s.T5 !== 0) {
|
||||||
|
return 'ruleGenInvalidT5RealEv'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateTierInput {
|
||||||
|
grids: number[]
|
||||||
|
constraints: TierCountConstraints
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateTierResultOk {
|
||||||
|
ok: true
|
||||||
|
tiers: IndexTier[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateTierResultFail {
|
||||||
|
ok: false
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GenerateTierResult = GenerateTierResultOk | GenerateTierResultFail
|
||||||
|
|
||||||
|
export function computeBoardFrequencies(grids: number[]): BoardFrequencies | null {
|
||||||
|
if (grids.length !== BOARD_SIZE) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const gridToPos: Record<number, number> = {}
|
||||||
|
for (let pos = 0; pos < BOARD_SIZE; pos++) {
|
||||||
|
const g = grids[pos]
|
||||||
|
if (g < GRID_MIN || g > GRID_MAX) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (gridToPos[g] !== undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
gridToPos[g] = pos
|
||||||
|
}
|
||||||
|
const freqCw = new Array<number>(BOARD_SIZE).fill(0)
|
||||||
|
const freqCcw = new Array<number>(BOARD_SIZE).fill(0)
|
||||||
|
for (let roll = GRID_MIN; roll <= GRID_MAX; roll++) {
|
||||||
|
const startPos = gridToPos[roll]
|
||||||
|
const endCw = (startPos + roll) % BOARD_SIZE
|
||||||
|
const endCcw = startPos - roll >= 0 ? startPos - roll : BOARD_SIZE + startPos - roll
|
||||||
|
freqCw[endCw]++
|
||||||
|
freqCcw[endCcw]++
|
||||||
|
}
|
||||||
|
const leopardLandCw = new Set<number>()
|
||||||
|
const leopardLandCcw = new Set<number>()
|
||||||
|
for (let di = 0; di < LEOPARD_ROLLS.length; di++) {
|
||||||
|
const d = LEOPARD_ROLLS[di]
|
||||||
|
const sp = gridToPos[d]
|
||||||
|
leopardLandCw.add((sp + d) % BOARD_SIZE)
|
||||||
|
const eccw = sp - d >= 0 ? sp - d : BOARD_SIZE + sp - d
|
||||||
|
leopardLandCcw.add(eccw)
|
||||||
|
}
|
||||||
|
const leopardLandUnion = new Set<number>()
|
||||||
|
leopardLandCw.forEach((x) => leopardLandUnion.add(x))
|
||||||
|
leopardLandCcw.forEach((x) => leopardLandUnion.add(x))
|
||||||
|
return {
|
||||||
|
grids: [...grids],
|
||||||
|
freqCw,
|
||||||
|
freqCcw,
|
||||||
|
leopardLandCw,
|
||||||
|
leopardLandCcw,
|
||||||
|
leopardLandUnion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumWeighted(freq: number[], tiers: IndexTier[], tier: IndexTier): number {
|
||||||
|
let s = 0
|
||||||
|
for (let i = 0; i < BOARD_SIZE; i++) {
|
||||||
|
if (tiers[i] === tier) {
|
||||||
|
s += freq[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function meetsConstraints(
|
||||||
|
tiers: IndexTier[],
|
||||||
|
freqCw: number[],
|
||||||
|
freqCcw: number[],
|
||||||
|
c: TierCountConstraints
|
||||||
|
): boolean {
|
||||||
|
const cw1 = sumWeighted(freqCw, tiers, 'T1')
|
||||||
|
const cw2 = sumWeighted(freqCw, tiers, 'T2')
|
||||||
|
const cw4 = sumWeighted(freqCw, tiers, 'T4')
|
||||||
|
const cw5 = sumWeighted(freqCw, tiers, 'T5')
|
||||||
|
const cc1 = sumWeighted(freqCcw, tiers, 'T1')
|
||||||
|
const cc2 = sumWeighted(freqCcw, tiers, 'T2')
|
||||||
|
const cc4 = sumWeighted(freqCcw, tiers, 'T4')
|
||||||
|
const cc5 = sumWeighted(freqCcw, tiers, 'T5')
|
||||||
|
return (
|
||||||
|
cw1 >= c.t1MinCw &&
|
||||||
|
cw2 >= c.t2MinCw &&
|
||||||
|
cw4 === c.t4FixedCw &&
|
||||||
|
cw5 === c.t5FixedCw &&
|
||||||
|
cc1 >= c.t1MinCcw &&
|
||||||
|
cc2 >= c.t2MinCcw &&
|
||||||
|
cc4 === c.t4FixedCcw &&
|
||||||
|
cc5 === c.t5FixedCcw
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonLeopardTierChoices(c: TierCountConstraints): IndexTier[] {
|
||||||
|
const out: IndexTier[] = ['T1', 'T2', 'T3']
|
||||||
|
if (c.t4FixedCw > 0 || c.t4FixedCcw > 0) {
|
||||||
|
out.push('T4')
|
||||||
|
}
|
||||||
|
if (c.t5FixedCw > 0 || c.t5FixedCcw > 0) {
|
||||||
|
out.push('T5')
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleIndices(rand: () => number): number[] {
|
||||||
|
const a: number[] = []
|
||||||
|
for (let i = 0; i < BOARD_SIZE; i++) {
|
||||||
|
a.push(i)
|
||||||
|
}
|
||||||
|
for (let i = BOARD_SIZE - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(rand() * (i + 1))
|
||||||
|
const t = a[i]
|
||||||
|
a[i] = a[j]
|
||||||
|
a[j] = t
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
return () => {
|
||||||
|
let t = (seed += 0x6d2b79f5)
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 随机搜索可行档位:豹子落点禁 T4/T5;T4/T5 顺/逆加权条数分别等于约束中的固定值(可多条 T4/T5 格位)。
|
||||||
|
*/
|
||||||
|
export function generateTiers(input: GenerateTierInput): GenerateTierResult {
|
||||||
|
const board = computeBoardFrequencies(input.grids)
|
||||||
|
if (board === null) {
|
||||||
|
return { ok: false, message: 'grid_number 须为 5~30 各出现一次且共 26 条' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { freqCw, freqCcw, leopardLandUnion } = board
|
||||||
|
const c = input.constraints
|
||||||
|
|
||||||
|
let seed = 0
|
||||||
|
for (let i = 0; i < BOARD_SIZE; i++) {
|
||||||
|
seed = (seed + input.grids[i] * 31 + i) | 0
|
||||||
|
}
|
||||||
|
const rand = mulberry32(seed === 0 ? 0x9e3779b9 : seed)
|
||||||
|
|
||||||
|
const needT5Cell = c.t5FixedCw > 0 || c.t5FixedCcw > 0
|
||||||
|
if (needT5Cell) {
|
||||||
|
let hasNonLeopard = false
|
||||||
|
for (let i = 0; i < BOARD_SIZE; i++) {
|
||||||
|
if (!leopardLandUnion.has(i)) {
|
||||||
|
hasNonLeopard = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasNonLeopard) {
|
||||||
|
return { ok: false, message: '无可用 T5 格位(豹子摇取落点占满全盘,请调整 grid_number 排布)' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonLeopardChoices = nonLeopardTierChoices(c)
|
||||||
|
const maxAttempts = 400000
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
const tiers: IndexTier[] = new Array(BOARD_SIZE).fill('T3')
|
||||||
|
const order = shuffleIndices(rand)
|
||||||
|
for (let oi = 0; oi < order.length; oi++) {
|
||||||
|
const pos = order[oi]
|
||||||
|
if (leopardLandUnion.has(pos)) {
|
||||||
|
tiers[pos] = T_NO_LEOPARD[Math.floor(rand() * T_NO_LEOPARD.length)]
|
||||||
|
} else {
|
||||||
|
tiers[pos] = nonLeopardChoices[Math.floor(rand() * nonLeopardChoices.length)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meetsConstraints(tiers, freqCw, freqCcw, c)) {
|
||||||
|
return { ok: true, tiers }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, message: '在当前盘面与约束下未找到可行解,请放宽 T1/T2 下限或调整 T4/T5 固定条数后重试' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展示文案:100 + 真实结算(中英文相同);T5 不使用 */
|
||||||
|
function uiTextFromRealEvPlus100(realEv: number): { ui_text: string; ui_text_en: string } {
|
||||||
|
const s = String(100 + realEv)
|
||||||
|
return { ui_text: s, ui_text_en: s }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 tier 生成展示字段。
|
||||||
|
* @param standards 若传入,则各档位统一使用对应 real_ev 标准;不传则使用内置随机占位(兼容脚本/旧逻辑)
|
||||||
|
*/
|
||||||
|
export function buildRowsFromTiers(
|
||||||
|
grids: number[],
|
||||||
|
tiers: IndexTier[],
|
||||||
|
standards?: TierRealEvStandards
|
||||||
|
): Array<{
|
||||||
|
id: number
|
||||||
|
grid_number: number
|
||||||
|
ui_text: string
|
||||||
|
ui_text_en: string
|
||||||
|
real_ev: number
|
||||||
|
tier: IndexTier
|
||||||
|
remark: string
|
||||||
|
}> {
|
||||||
|
const rows: Array<{
|
||||||
|
id: number
|
||||||
|
grid_number: number
|
||||||
|
ui_text: string
|
||||||
|
ui_text_en: string
|
||||||
|
real_ev: number
|
||||||
|
tier: IndexTier
|
||||||
|
remark: string
|
||||||
|
}> = []
|
||||||
|
let t4Seq = 0
|
||||||
|
for (let id = 0; id < BOARD_SIZE; id++) {
|
||||||
|
const tier = tiers[id]
|
||||||
|
const grid_number = grids[id]
|
||||||
|
let ui_text = ''
|
||||||
|
let ui_text_en = ''
|
||||||
|
let real_ev = 0
|
||||||
|
let remark = ''
|
||||||
|
|
||||||
|
if (standards !== undefined) {
|
||||||
|
if (tier === 'T1') {
|
||||||
|
real_ev = standards.T1
|
||||||
|
const f = uiTextFromRealEvPlus100(real_ev)
|
||||||
|
ui_text = f.ui_text
|
||||||
|
ui_text_en = f.ui_text_en
|
||||||
|
remark = '大奖格'
|
||||||
|
} else if (tier === 'T2') {
|
||||||
|
real_ev = standards.T2
|
||||||
|
const f = uiTextFromRealEvPlus100(real_ev)
|
||||||
|
ui_text = f.ui_text
|
||||||
|
ui_text_en = f.ui_text_en
|
||||||
|
remark = standards.T2 <= 1 ? '完美回本' : '小赚'
|
||||||
|
} else if (tier === 'T3') {
|
||||||
|
real_ev = standards.T3
|
||||||
|
const f = uiTextFromRealEvPlus100(real_ev)
|
||||||
|
ui_text = f.ui_text
|
||||||
|
ui_text_en = f.ui_text_en
|
||||||
|
remark = '抽水'
|
||||||
|
} else if (tier === 'T4') {
|
||||||
|
real_ev = standards.T4
|
||||||
|
const f = uiTextFromRealEvPlus100(real_ev)
|
||||||
|
ui_text = f.ui_text
|
||||||
|
ui_text_en = f.ui_text_en
|
||||||
|
remark = '惩罚'
|
||||||
|
} else {
|
||||||
|
real_ev = standards.T5
|
||||||
|
ui_text = '再来一次'
|
||||||
|
ui_text_en = 'Once again'
|
||||||
|
remark = '前端需要在播放一次动画(特殊)'
|
||||||
|
}
|
||||||
|
} else if (tier === 'T1') {
|
||||||
|
real_ev = 101 + ((id * 17 + grid_number * 3) % 398)
|
||||||
|
if (real_ev >= 500) {
|
||||||
|
real_ev = 498
|
||||||
|
}
|
||||||
|
const f = uiTextFromRealEvPlus100(real_ev)
|
||||||
|
ui_text = f.ui_text
|
||||||
|
ui_text_en = f.ui_text_en
|
||||||
|
remark = '大奖格'
|
||||||
|
} else if (tier === 'T2') {
|
||||||
|
if (id % 3 === 0) {
|
||||||
|
real_ev = 1
|
||||||
|
remark = '完美回本'
|
||||||
|
} else {
|
||||||
|
real_ev = 20 + ((id * 11) % 75)
|
||||||
|
if (real_ev <= 0) {
|
||||||
|
real_ev = 50
|
||||||
|
}
|
||||||
|
remark = '小赚'
|
||||||
|
}
|
||||||
|
const f = uiTextFromRealEvPlus100(real_ev)
|
||||||
|
ui_text = f.ui_text
|
||||||
|
ui_text_en = f.ui_text_en
|
||||||
|
} else if (tier === 'T3') {
|
||||||
|
real_ev = -72 - (id % 15)
|
||||||
|
const f = uiTextFromRealEvPlus100(real_ev)
|
||||||
|
ui_text = f.ui_text
|
||||||
|
ui_text_en = f.ui_text_en
|
||||||
|
remark = '抽水'
|
||||||
|
} else if (tier === 'T4') {
|
||||||
|
t4Seq++
|
||||||
|
real_ev = -101 - t4Seq * 15
|
||||||
|
const f = uiTextFromRealEvPlus100(real_ev)
|
||||||
|
ui_text = f.ui_text
|
||||||
|
ui_text_en = f.ui_text_en
|
||||||
|
remark = '惩罚'
|
||||||
|
} else {
|
||||||
|
real_ev = 0
|
||||||
|
ui_text = '再来一次'
|
||||||
|
ui_text_en = 'Once again'
|
||||||
|
remark = '前端需要在播放一次动画(特殊)'
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
id,
|
||||||
|
grid_number,
|
||||||
|
ui_text,
|
||||||
|
ui_text_en,
|
||||||
|
real_ev,
|
||||||
|
tier,
|
||||||
|
remark
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeCounts(
|
||||||
|
board: BoardFrequencies,
|
||||||
|
tiers: IndexTier[]
|
||||||
|
): { cw: Record<IndexTier, number>; ccw: Record<IndexTier, number> } {
|
||||||
|
const cw: Record<IndexTier, number> = { T1: 0, T2: 0, T3: 0, T4: 0, T5: 0 }
|
||||||
|
const ccw: Record<IndexTier, number> = { T1: 0, T2: 0, T3: 0, T4: 0, T5: 0 }
|
||||||
|
for (let k = 0; k < T_ALL.length; k++) {
|
||||||
|
const t = T_ALL[k]
|
||||||
|
cw[t] = sumWeighted(board.freqCw, tiers, t)
|
||||||
|
ccw[t] = sumWeighted(board.freqCcw, tiers, t)
|
||||||
|
}
|
||||||
|
return { cw, ccw }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user