1.优化奖励配置设置

This commit is contained in:
2026-06-02 14:51:10 +08:00
parent 3f97905ffa
commit 79c84c198a
10 changed files with 662 additions and 231 deletions

View File

@@ -8,17 +8,18 @@
"tabIndex": "Reward Index",
"tabBigwin": "Big Win Weights",
"tipIndex": "Dice points must be between 5 and 30 and unique in this table.",
"tierRecommendRules": "[Settlement vs tier] Settlement < 0 → T4; 0 < Settlement < 1 → T3; 1 < Settlement < 2 → T2; Settlement > 2 → T1; T5 “try again” settlement = 0. Set a unified settlement standard per tier below; values are written on generation and can be edited row by row in the table.",
"tierRecommendRules": "[Settlement vs tier] T1 (big prize): >2; T2 (small win): 2≥amount>1; T3 (rake): 1≥amount>0; T4 (try again): =0; T5 (penalty): 0>amount. Set recommended settlement per tier below. The Tier column is auto-calculated from settlement and cannot be edited manually.",
"tierRecommendRealEv": "Recommended settlement",
"tierRecommendAutoMatch": "Auto-match tier when settlement changes",
"tierRecommendApplyAmount": "Fill recommended amount for rows with tier set",
"tierRecommendApplyAmountOk": "Filled recommended settlement for {n} row(s)",
"tierRecommendNoTierRows": "Set tier T1T5 on table rows first",
"tierRecommendNoTierRows": "No rows with a tier inferable from settlement",
"tierRecommendMatchTier": "Match all tiers from settlement",
"tierRecommendMatchTierOk": "Matched tier for {n} row(s) from settlement",
"tierRecommendMatchTierNone": "No rows to match",
"tierRecommendT5UiText": "再来一次",
"tierRecommendT5UiTextEn": "Once again",
"tierRecommendT4UiText": "再来一次",
"tierRecommendT4UiTextEn": "Once again",
"colTierAutoHint": "Auto-matched from settlement",
"tipBigwin": "Left to right: big-win points (read-only), display text, real EV, remark, weight (0~10000). Points 5 and 30 are fixed at 100%. This tab saves big-win weights only.",
"colId": "Index (id)",
"colDicePoints": "Dice Points",
@@ -47,6 +48,18 @@
"confirmCreateRefMsg": "Create reward reference by rule: start_index is the id of the cell for grid_number in reward config; clockwise end_index=(start_index+roll)%26; counter-clockwise end_index=start_index-roll if >=0 else 26+start_index-roll. Existing data will be cleared, then 26 points (530) for both directions will be generated. Continue?",
"confirmCreateRefOk": "Create",
"confirmCreateRefCancel": "Cancel",
"createRefPreviewTitle": "Create Reward Reference Preview",
"createRefPreviewClockwise": "Clockwise",
"createRefPreviewCounterclockwise": "Counter-clockwise",
"createRefPreviewTipUnchanged": "Dice points mapping is unchanged: weights in the preview are reused from current dice_reward; importing will not override existing weights.",
"createRefPreviewTipChanged": "Dice points mapping has changed: preview weights use the default value (1). After importing, adjust weights in the Dice Reward page if needed.",
"createRefPreviewSkipped": "{n} dice point(s) are missing in the reward index and were skipped (please complete all 26 points from 5 to 30).",
"createRefPreviewRefresh": "Refresh preview",
"createRefPreviewImport": "Import",
"createRefPreviewImportOk": "Imported reward reference",
"createRefPreviewImportNoop": "Mapping unchanged, nothing to import (existing weights kept)",
"createRefPreviewDiff": "Diff (old → new)",
"createRefPreviewNoDiff": "No change",
"createRefSuccess": "Created for 26 dice points (530), clockwise + counter-clockwise: clockwise added {cwNew}, counter-clockwise added {ccwNew}; clockwise updated {cwUp}, counter-clockwise updated {ccwUp}{skippedPart}",
"createRefSuccessSkipped": "; {n} point(s) used fallback start index",
"createRefSuccessSimple": "Created successfully",
@@ -65,21 +78,21 @@
"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 025; each rows grid_number is 530 and unique.\n• Roll D (530): 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 rows “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 / once again).\n\n[Settlement amount vs tier]\nSettlement < 0 → T4; 0 < Settlement < 1 → T3; 1 < Settlement < 2 → T2; Settlement > 2 → T1; T5 “once again” settlement = 0. You can set a unified settlement standard for each tier below; generated rows write those values into the config, and details can be edited later in the table.\n\n[Inputs in this dialog]\nCount: T1/T4/T5 are fixed; T2 is minimum. Clockwise and counter-clockwise weighted counts (each roll result counts once) must each satisfy the entered values; T1, T4, and T5 are entered separately.\nSettlement standard: all cells in the same tier use the same value. On generation, T1T4 use ui_text / ui_text_en = settlement real_ev; T5 is fixed to \"再来一次\" / \"Once again\". Remarks still distinguish break-even / small win, etc.",
"ruleGenerateRules": "[Generation logic (same as Create Reward Reference)]\n• 26 cells ordered by id ascending are positions 025; each rows grid_number is 530 and unique.\n• Roll D (530): 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 rows “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 + try again / penalty).\n\n[Settlement vs tier]\nT1: >2; T2: 2≥amount>1; T3: 1≥amount>0; T4: =0; T5: 0>amount. Set recommended settlement per tier below.\n\n[Inputs in this dialog]\nCount: T1/T4/T5 are fixed; T2 is minimum. Clockwise and counter-clockwise weighted counts must each satisfy the entered values.\nSettlement standard: same tier uses the same value. T1T3 and T5 use ui_text = settlement; T4 is fixed to \"再来一次\" / \"Once again\".",
"ruleGenT1Row": "T1 (big prize)",
"ruleGenT2Row": "T2 (small win / break-even)",
"ruleGenT3RealEvOnly": "T3 (rake)",
"ruleGenT4Row": "T4 (penalty)",
"ruleGenT5Row": "T5 (try again)",
"ruleGenT4Row": "T4 (try again)",
"ruleGenT5Row": "T5 (penalty)",
"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 settlement amount must satisfy: value > 2",
"ruleGenInvalidT2RealEv": "T2 settlement amount must satisfy: 1 < value < 2",
"ruleGenInvalidT3RealEv": "T3 settlement amount must satisfy: 0 < value < 1",
"ruleGenInvalidT4RealEv": "T4 settlement amount must satisfy: value < 0",
"ruleGenInvalidT5RealEv": "T5 “try again” real_ev must be 0",
"ruleGenInvalidT1RealEv": "T1 (big prize) settlement must satisfy: value > 2",
"ruleGenInvalidT2RealEv": "T2 (small win) settlement must satisfy: 1 < value 2",
"ruleGenInvalidT3RealEv": "T3 (rake) settlement must satisfy: 0 < value 1",
"ruleGenInvalidT4RealEv": "T4 (try again) settlement must be 0",
"ruleGenInvalidT5RealEv": "T5 (penalty) settlement must satisfy: value < 0",
"ruleGenT1Min": "T1 fixed count (CW & CCW)",
"ruleGenT2Min": "T2 min (CW & CCW)",
"ruleGenT4Max": "T4 fixed count (CW & CCW)",

View File

@@ -8,17 +8,18 @@
"tabIndex": "奖励索引",
"tabBigwin": "大奖权重",
"tipIndex": "色子点数须在 530 之间且本表内不重复。",
"tierRecommendRules": "【结算金额与档位】结算金额 < 0 → T40 < 结算金额 < 1 → T31 < 结算金额 < 2 → T22 < 结算金额 → T1T5「再来一次」结算金额 = 0。下方可为各档位填写统一的结算金额标准,生成时写入配置;细则可稍后在表格中再改。",
"tierRecommendRules": "【结算金额与档位】【大奖】T1结算金额>2【小赚】T22>=结算金额>1【抽水】T31>=结算金额>0【再来一次】T4结算金额=0【惩罚】T50>结算金额。下方可为各档位填写推荐结算金额;表格中「所属档位」随结算金额自动计算,不可手动修改。",
"tierRecommendRealEv": "推荐结算金额",
"tierRecommendAutoMatch": "修改结算金额时自动匹配档位",
"tierRecommendApplyAmount": "将推荐金额填入已选档位的行",
"tierRecommendApplyAmountOk": "已为 {n} 行填入推荐结算金额",
"tierRecommendNoTierRows": "请先在表格中为行选择 T1T5 档位",
"tierRecommendNoTierRows": "没有可根据结算金额推断档位的行",
"tierRecommendMatchTier": "按结算金额匹配全部档位",
"tierRecommendMatchTierOk": "已根据结算金额为 {n} 行匹配档位",
"tierRecommendMatchTierNone": "没有可匹配档位的行",
"tierRecommendT5UiText": "再来一次",
"tierRecommendT5UiTextEn": "Once again",
"tierRecommendT4UiText": "再来一次",
"tierRecommendT4UiTextEn": "Once again",
"colTierAutoHint": "根据结算金额自动匹配",
"tipBigwin": "从左至右:中大奖点数(不可改)、显示信息、实际中奖、备注、权重(0~10000)。点数 5、30 权重固定 100%。本表单独立提交,仅提交大奖权重。",
"colId": "索引(id)",
"colDicePoints": "色子点数",
@@ -47,6 +48,18 @@
"confirmCreateRefMsg": "按规则创建奖励对照:起始索引 start_index=奖励配置中 grid_number 对应格位的 id顺时针 end_index=(start_index+摇取点数)%26逆时针 end_index=start_index-摇取点数≥0 则取该值,否则 26+start_index-摇取点数。先清空现有数据再为 5-30 共 26 个点数、顺/逆时针分别生成。是否继续?",
"confirmCreateRefOk": "确定创建",
"confirmCreateRefCancel": "取消",
"createRefPreviewTitle": "创建奖励对照预览",
"createRefPreviewClockwise": "顺时针",
"createRefPreviewCounterclockwise": "逆时针",
"createRefPreviewTipUnchanged": "检测到色子点数映射未变化预览中权重将复用当前奖励对照表dice_reward的权重导入时不会覆盖现有权重。",
"createRefPreviewTipChanged": "检测到色子点数映射已变化预览中权重将使用默认值1确认导入后可再到「奖励对照」页面调整权重。",
"createRefPreviewSkipped": "有 {n} 个点数在当前奖励索引中缺失,已跳过生成(请先补齐 530 共 26 个点数)。",
"createRefPreviewRefresh": "刷新预览",
"createRefPreviewImport": "确认导入",
"createRefPreviewImportOk": "已导入奖励对照表",
"createRefPreviewImportNoop": "色子点数映射未变化,无需导入(已保留现有权重)",
"createRefPreviewDiff": "差异(旧 → 新)",
"createRefPreviewNoDiff": "无变化",
"createRefSuccess": "已按 5-30 共 26 个点数、顺时针+逆时针创建:顺时针新增 {cwNew} 条、逆时针新增 {ccwNew} 条;顺时针更新 {cwUp} 条、逆时针更新 {ccwUp} 条{skippedPart}",
"createRefSuccessSkipped": "{n} 个点数使用兜底起始索引",
"createRefSuccessSimple": "创建成功",
@@ -65,21 +78,21 @@
"infoNoBigwin": "暂无 BIGWIN 档位配置,请先在「奖励索引」中设置 tier 为 BIGWIN",
"btnRuleGenerate": "按规则生成",
"ruleGenerateTitle": "按规则生成奖励索引",
"ruleGenerateRules": "【生成逻辑(与创建奖励对照一致)】\n• 盘面 26 格按 id 升序为位置 025每条配置的 grid_number 为 530 且不重复。\n• 摇取点数 D530起点为「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【结算金额 与 档位】\n结算金额 < 0 → T40 < 结算金额 < 1 → T31 < 结算金额 < 2 → T22 < 结算金额 → T1T5「再来一次」结算金额=0。下方可为各档位填写统一的 结算金额 标准,生成时写入配置;细则可稍后在表格中再改。\n\n【本弹窗输入】\n条数T1/T4/T5「固定」T2「不少于」——顺时针与逆时针的加权条数每条摇取结果计一次须分别满足所填数值T1、T4 与 T5 分开填写。\n结算金额 标准:同档位各格使用同一数值。生成时 T1T4 显示文本 / 显示文本(英文) = 结算金额T5 固定为「再来一次」/「Once again」。备注仍区分完美回本/小赚等。",
"ruleGenerateRules": "【生成逻辑(与创建奖励对照一致)】\n• 盘面 26 格按 id 升序为位置 025每条配置的 grid_number 为 530 且不重复。\n• 摇取点数 D530起点为「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【结算金额 与 档位】\n【大奖】T1>2【小赚】T22>=金额>1【抽水】T31>=金额>0【再来一次】T4=0【惩罚】T50>金额。下方可为各档位填写推荐结算金额标准,生成时写入配置。\n\n【本弹窗输入】\n条数T1/T4/T5「固定」T2「不少于」——顺时针与逆时针的加权条数每条摇取结果计一次须分别满足所填数值T1、T4 与 T5 分开填写。\n结算金额 标准:同档位各格使用同一数值。生成时 T1T3、T5 的显示文本 = 结算金额T4 固定为「再来一次」/「Once again」。备注仍区分完美回本/小赚等。",
"ruleGenT1Row": "T1 大奖",
"ruleGenT2Row": "T2 小赚/回本",
"ruleGenT3RealEvOnly": "T3 抽水",
"ruleGenT4Row": "T4 惩罚",
"ruleGenT5Row": "T5 再来一次",
"ruleGenT4Row": "T4 再来一次",
"ruleGenT5Row": "T5 惩罚",
"ruleGenMinCount": "最少条数",
"ruleGenFixedCount": "固定条数(顺/逆)",
"ruleGenRealEvStd": "结算金额",
"ruleGenRealEvEditHint": "生成并保存后,仍可在本页表格中逐条修改显示文案、英文、真实结算与备注。",
"ruleGenInvalidT1RealEv": "T1结算金额 满足:2 < 值",
"ruleGenInvalidT2RealEv": "T2结算金额 满足1 < 值 < 2",
"ruleGenInvalidT3RealEv": "T3结算金额 满足0 < 值 < 1",
"ruleGenInvalidT4RealEv": "T4 的 结算金额 满足:值 < 0",
"ruleGenInvalidT5RealEv": "T5「再来一次」的 结算金额 须为 0",
"ruleGenInvalidT1RealEv": "T1(大奖)结算金额满足:值 > 2",
"ruleGenInvalidT2RealEv": "T2(小赚)结算金额满足1 < 值 2",
"ruleGenInvalidT3RealEv": "T3(抽水)结算金额满足0 < 值 1",
"ruleGenInvalidT4RealEv": "T4(再来一次)结算金额须为 0",
"ruleGenInvalidT5RealEv": "T5(惩罚)结算金额须满足:值 < 0",
"ruleGenT1Min": "T1 固定条数(顺/逆)",
"ruleGenT2Min": "T2 最少条数(顺/逆)",
"ruleGenT4Max": "T4 固定条数(顺/逆)",

View File

@@ -147,5 +147,15 @@ export default {
url: '/core/dice/reward_config/DiceRewardConfig/createRewardReference',
data: params || {}
})
},
/**
* 创建奖励对照(预览):不写库,返回将要生成的对照与权重预览
*/
createRewardReferencePreview(params?: Record<string, any>) {
return request.post<any>({
url: '/core/dice/reward_config/DiceRewardConfig/createRewardReferencePreview',
data: params || {}
})
}
}

View File

@@ -29,7 +29,7 @@
<span class="tier-recommend-hint">{{ $t('page.configPage.tierRecommendRealEv') }}</span>
<ElInputNumber
v-model="tierRecommend[tk]"
:disabled="tk === 'T5'"
:disabled="tk === 'T4'"
:step="0.1"
:precision="2"
controls-position="right"
@@ -131,7 +131,8 @@
@change="handleRealEvChange(row)"
controls-position="right"
size="small"
:step="1"
:step="0.1"
:precision="2"
class="full-width"
/>
</template>
@@ -147,19 +148,7 @@
</ElTableColumn>
<ElTableColumn :label="$t('page.configPage.colTier')" width="100" align="center">
<template #default="{ row }">
<ElSelect
v-model="row.tier"
:placeholder="$t('page.configPage.placeholderTierSelect')"
clearable
size="small"
class="full-width"
>
<ElOption label="T1" value="T1" />
<ElOption label="T2" value="T2" />
<ElOption label="T3" value="T3" />
<ElOption label="T4" value="T4" />
<ElOption label="T5" value="T5" />
</ElSelect>
<span class="tier-readonly">{{ displayRowTier(row) }}</span>
</template>
</ElTableColumn>
<ElTableColumn
@@ -246,7 +235,8 @@
@change="handleRealEvChange(row)"
controls-position="right"
size="small"
:step="1"
:step="0.1"
:precision="2"
class="full-width"
/>
</template>
@@ -423,6 +413,7 @@
<ElInputNumber
v-model="ruleGenT4RealEv"
class="rule-gen-input-num"
:disabled="true"
:step="1"
:controls="ruleGenInputControls"
:size="ruleGenInputSize"
@@ -451,8 +442,8 @@
<ElInputNumber
v-model="ruleGenT5RealEv"
class="rule-gen-input-num"
:disabled="true"
:step="1"
:step="0.1"
:precision="2"
:controls="ruleGenInputControls"
:size="ruleGenInputSize"
controls-position="right"
@@ -472,6 +463,12 @@
</div>
</template>
</ElDialog>
<CreateRewardReferencePreviewDialog
v-model="createRewardPreviewVisible"
:dept-id="filterDeptId"
@success="loadIndexList"
/>
</div>
</template>
@@ -483,10 +480,11 @@
useInjectedChannelDept
} from '@/composables/useChannelDeptScope'
import { useWindowSize } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api from '../../api/reward_config/index'
import { checkAuth } from '@/utils/tool'
import CreateRewardReferencePreviewDialog from './modules/create-reward-reference-preview-dialog.vue'
import {
buildRowsFromTiers,
computeBoardFrequencies,
@@ -547,6 +545,7 @@
const savingIndex = ref(false)
const savingBigwin = ref(false)
const createRewardLoading = ref(false)
const createRewardPreviewVisible = ref(false)
const ruleGenerateDialogVisible = ref(false)
const ruleGenSubmitting = ref(false)
const ruleGenT1Fixed = ref(3)
@@ -593,15 +592,34 @@
row.ui_text_en = text
}
function rowRealEvNumber(row: IndexRow): number {
return typeof row.real_ev === 'number' && !Number.isNaN(row.real_ev)
? row.real_ev
: Number(row.real_ev)
}
function syncRowTierFromRealEv(row: IndexRow) {
const tier = inferTierFromRealEv(rowRealEvNumber(row))
if (tier !== '') {
row.tier = tier
}
}
function displayRowTier(row: IndexRow): string {
const tier = inferTierFromRealEv(rowRealEvNumber(row))
return tier !== '' ? tier : row.tier || '-'
}
function applyRecommendRealEvToRow(row: IndexRow, tier: TierRecommendKey) {
const ev = tierRecommend[tier]
row.real_ev = ev
if (tier === 'T5') {
row.ui_text = t('page.configPage.tierRecommendT5UiText')
row.ui_text_en = t('page.configPage.tierRecommendT5UiTextEn')
if (tier === 'T4') {
row.ui_text = t('page.configPage.tierRecommendT4UiText')
row.ui_text_en = t('page.configPage.tierRecommendT4UiTextEn')
} else {
applyRealEvDisplay(row, ev)
}
syncRowTierFromRealEv(row)
}
/** 奖励索引 id 与后端 DiceRewardConfigLogic 一致025 */
@@ -655,16 +673,13 @@
}
function handleRealEvChange(row: IndexRow) {
const n =
typeof row.real_ev === 'number' && !Number.isNaN(row.real_ev)
? row.real_ev
: Number(row.real_ev)
applyRealEvDisplay(row, n)
if (canTierRecommend.value && autoMatchTierOnRealEv.value) {
const tier = inferTierFromRealEv(n)
if (tier !== '') {
row.tier = tier
}
const n = rowRealEvNumber(row)
syncRowTierFromRealEv(row)
if (row.tier === 'T4') {
row.ui_text = t('page.configPage.tierRecommendT4UiText')
row.ui_text_en = t('page.configPage.tierRecommendT4UiTextEn')
} else {
applyRealEvDisplay(row, n)
}
}
@@ -674,7 +689,7 @@
}
let count = 0
for (const row of indexRowsExcludeBigwin.value) {
const tier = row.tier
const tier = inferTierFromRealEv(rowRealEvNumber(row))
if (tier === 'T1' || tier === 'T2' || tier === 'T3' || tier === 'T4' || tier === 'T5') {
applyRecommendRealEvToRow(row, tier)
count++
@@ -693,13 +708,8 @@
}
let count = 0
for (const row of indexRowsExcludeBigwin.value) {
const n =
typeof row.real_ev === 'number' && !Number.isNaN(row.real_ev)
? row.real_ev
: Number(row.real_ev)
const tier = inferTierFromRealEv(n)
if (tier !== '') {
row.tier = tier
syncRowTierFromRealEv(row)
if (row.tier !== '') {
count++
}
}
@@ -718,43 +728,7 @@
}
async function handleCreateRewardReference() {
try {
await ElMessageBox.confirm(
t('page.configPage.confirmCreateRefMsg'),
t('page.configPage.confirmCreateRefTitle'),
{
confirmButtonText: t('page.configPage.confirmCreateRefOk'),
cancelButtonText: t('page.configPage.confirmCreateRefCancel'),
type: 'warning'
}
)
} catch {
return
}
createRewardLoading.value = true
try {
const res: any = await api.createRewardReference({ dept_id: filterDeptId.value as number })
const data = res?.data ?? res
let msg = t('page.configPage.createRefSuccessSimple')
if (typeof data === 'object' && data !== null) {
const skipped = Number(data.skipped ?? 0)
const skippedPart =
skipped > 0 ? t('page.configPage.createRefSuccessSkipped', { n: skipped }) : ''
msg = t('page.configPage.createRefSuccess', {
cwNew: data.created_clockwise ?? 0,
ccwNew: data.created_counterclockwise ?? 0,
cwUp: data.updated_clockwise ?? 0,
ccwUp: data.updated_counterclockwise ?? 0,
skippedPart
})
}
ElMessage.success(msg)
loadIndexList()
} catch (e: any) {
ElMessage.error(e?.message ?? t('page.configPage.createRefFail'))
} finally {
createRewardLoading.value = false
}
createRewardPreviewVisible.value = true
}
function extractIndexList(res: unknown): Record<string, unknown>[] {
@@ -779,6 +753,11 @@
.list({ saiType: 'all', limit: 200, dept_id: filterDeptId.value })
.then((res: unknown) => {
const rows = extractIndexList(res).map((r) => normalizeIndexRow(r))
for (const row of rows) {
if (row.tier !== 'BIGWIN') {
syncRowTierFromRealEv(row)
}
}
indexRows.value = rows
indexRowsSnapshot = rows.map((r) => ({ ...r }))
})
@@ -1024,6 +1003,9 @@
return
}
const toSave = indexRows.value.filter((r) => r.tier !== 'BIGWIN')
for (const row of toSave) {
syncRowTierFromRealEv(row)
}
savingIndex.value = true
try {
const indexPayload = toSave.map((r) => ({
@@ -1239,6 +1221,10 @@
min-width: 100px;
max-width: 160px;
}
.tier-readonly {
font-weight: 500;
color: var(--el-text-color-primary);
}
.tier-recommend-actions {
display: flex;
flex-wrap: wrap;

View File

@@ -0,0 +1,243 @@
<template>
<ElDialog
v-model="visible"
:title="$t('page.configPage.createRefPreviewTitle')"
width="980px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<div v-loading="loading" class="dialog-body">
<div class="preview-tip">
<div v-if="meta.unchanged" class="tip-line">
{{ $t('page.configPage.createRefPreviewTipUnchanged') }}
</div>
<div v-else class="tip-line">
{{ $t('page.configPage.createRefPreviewTipChanged') }}
</div>
<div class="tip-line" v-if="meta.skipped > 0">
{{ $t('page.configPage.createRefPreviewSkipped', { n: meta.skipped }) }}
</div>
</div>
<ElTabs v-model="activeDirection" type="card" class="direction-tabs">
<ElTabPane :label="$t('page.configPage.createRefPreviewClockwise')" name="0">
<ElTabs v-model="activeTier" type="card" class="tier-tabs">
<ElTabPane v-for="t in tierKeys" :key="'cw-' + t" :label="t" :name="t">
<ElTable
:data="getTierItems(t, 0)"
:row-class-name="rowClassName"
border
size="small"
class="preview-table"
>
<ElTableColumn :label="$t('page.table.dicePoints')" prop="grid_number" width="120" align="center" />
<ElTableColumn label="start_index" prop="start_index" width="110" align="center" />
<ElTableColumn :label="$t('page.table.endIndex')" prop="id" width="110" align="center" />
<ElTableColumn :label="$t('page.table.tier')" prop="tier" width="90" align="center" />
<ElTableColumn :label="$t('page.table.displayText')" prop="ui_text" min-width="110" align="center" show-overflow-tooltip />
<ElTableColumn :label="$t('page.table.realEv')" prop="real_ev" width="110" align="center" show-overflow-tooltip />
<ElTableColumn :label="$t('page.table.remark')" prop="remark" min-width="140" align="center" show-overflow-tooltip />
<ElTableColumn :label="$t('page.table.weight')" prop="weight" width="110" align="center" />
<ElTableColumn :label="$t('page.configPage.createRefPreviewDiff')" min-width="220" align="center">
<template #default="{ row }">
<span v-if="row?.diff_changed" class="diff-text">
{{ formatDiff(row) }}
</span>
<span v-else class="diff-text diff-text-ok">{{ $t('page.configPage.createRefPreviewNoDiff') }}</span>
</template>
</ElTableColumn>
</ElTable>
</ElTabPane>
</ElTabs>
</ElTabPane>
<ElTabPane :label="$t('page.configPage.createRefPreviewCounterclockwise')" name="1">
<ElTabs v-model="activeTier" type="card" class="tier-tabs">
<ElTabPane v-for="t in tierKeys" :key="'ccw-' + t" :label="t" :name="t">
<ElTable
:data="getTierItems(t, 1)"
:row-class-name="rowClassName"
border
size="small"
class="preview-table"
>
<ElTableColumn :label="$t('page.table.dicePoints')" prop="grid_number" width="120" align="center" />
<ElTableColumn label="start_index" prop="start_index" width="110" align="center" />
<ElTableColumn :label="$t('page.table.endIndex')" prop="id" width="110" align="center" />
<ElTableColumn :label="$t('page.table.tier')" prop="tier" width="90" align="center" />
<ElTableColumn :label="$t('page.table.displayText')" prop="ui_text" min-width="110" align="center" show-overflow-tooltip />
<ElTableColumn :label="$t('page.table.realEv')" prop="real_ev" width="110" align="center" show-overflow-tooltip />
<ElTableColumn :label="$t('page.table.remark')" prop="remark" min-width="140" align="center" show-overflow-tooltip />
<ElTableColumn :label="$t('page.table.weight')" prop="weight" width="110" align="center" />
<ElTableColumn :label="$t('page.configPage.createRefPreviewDiff')" min-width="220" align="center">
<template #default="{ row }">
<span v-if="row?.diff_changed" class="diff-text">
{{ formatDiff(row) }}
</span>
<span v-else class="diff-text diff-text-ok">{{ $t('page.configPage.createRefPreviewNoDiff') }}</span>
</template>
</ElTableColumn>
</ElTable>
</ElTabPane>
</ElTabs>
</ElTabPane>
</ElTabs>
</div>
<template #footer>
<ElButton @click="refreshPreview" :loading="loading" v-ripple>{{
$t('page.configPage.createRefPreviewRefresh')
}}</ElButton>
<ElButton @click="handleClose" v-ripple>{{ $t('common.cancel') }}</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleImport" v-ripple>{{
$t('page.configPage.createRefPreviewImport')
}}</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api from '../../../api/reward_config/index'
const { t } = useI18n()
const TIER_KEYS = ['T1', 'T2', 'T3', 'T4', 'T5'] as const
const tierKeys = TIER_KEYS
interface Props {
modelValue: boolean
deptId: number
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
deptId: 0
})
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const activeDirection = ref<'0' | '1'>('0')
const activeTier = ref('T1')
const loading = ref(false)
const submitting = ref(false)
const meta = reactive<{ unchanged: boolean; skipped: number }>({ unchanged: false, skipped: 0 })
const preview = ref<Record<string, { 0: any[]; 1: any[] }>>({})
function rowClassName({ row }: { row: any }): string {
return row?.diff_changed ? 'row-diff' : ''
}
function formatDiff(row: any): string {
if (!row) return ''
const oldStart = row.old_start_index != null ? String(row.old_start_index) : '-'
const oldEnd = row.old_end_index != null ? String(row.old_end_index) : '-'
const oldTier = row.old_tier != null ? String(row.old_tier) : '-'
const newStart = row.start_index != null ? String(row.start_index) : '-'
const newEnd = row.id != null ? String(row.id) : '-'
const newTier = row.tier != null ? String(row.tier) : '-'
return `${oldStart}/${oldEnd}/${oldTier}${newStart}/${newEnd}/${newTier}`
}
function getTierItems(tier: string, direction: 0 | 1): any[] {
const tierData = preview.value?.[tier]
if (!tierData) return []
const rows = direction === 0 ? tierData[0] : tierData[1]
return Array.isArray(rows) ? rows : []
}
async function refreshPreview() {
loading.value = true
try {
const res: any = await api.createRewardReferencePreview({ dept_id: props.deptId })
const data = res?.data ?? res
meta.unchanged = Boolean(data?.unchanged)
meta.skipped = Number(data?.skipped ?? 0)
preview.value = (data?.preview ?? {}) as any
activeDirection.value = '0'
activeTier.value = 'T1'
} catch (e: any) {
ElMessage.error(e?.message ?? 'preview failed')
} finally {
loading.value = false
}
}
async function handleImport() {
submitting.value = true
try {
const res: any = await api.createRewardReference({ dept_id: props.deptId })
const data = res?.data ?? res
if (data?.unchanged) {
ElMessage.success(t('page.configPage.createRefPreviewImportNoop'))
} else {
ElMessage.success(t('page.configPage.createRefPreviewImportOk'))
}
emit('success')
handleClose()
} catch (e: any) {
ElMessage.error(e?.message ?? 'import failed')
} finally {
submitting.value = false
}
}
function handleClose() {
visible.value = false
}
watch(
() => props.modelValue,
(open) => {
if (open) {
refreshPreview()
}
}
)
</script>
<style scoped lang="scss">
.dialog-body {
min-height: 240px;
}
.preview-tip {
margin-bottom: 12px;
padding: 10px 12px;
font-size: 13px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
border-radius: 6px;
line-height: 1.5;
}
.tip-line + .tip-line {
margin-top: 4px;
}
.preview-table {
margin-top: 8px;
}
:deep(.row-diff td) {
background: var(--el-color-warning-light-9);
}
.diff-text {
color: var(--el-text-color-regular);
font-size: 12px;
line-height: 1.2;
word-break: break-all;
}
.diff-text-ok {
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -58,38 +58,36 @@ export const DEFAULT_TIER_REAL_EV_STANDARDS: TierRealEvStandards = {
T1: 3,
T2: 1.5,
T3: 0.5,
T4: -0.4,
T5: 0
T4: 0,
T5: -0.4
}
/**
* 按结算金额推断档位(与奖励配置页规则说明一致)
* 结算金额 < 0 → T40 < 结算金额 < 1 → T31 < 结算金额 < 2 → T22 < 结算金额 → T1T5 结算金额 = 0
* T1 大奖:>2
* T2 小赚2>=金额>1
* T3 抽水1>=金额>0
* T4 再来一次:=0
* T5 惩罚0>金额
*/
export function inferTierFromRealEv(realEv: number): IndexTier | '' {
if (!Number.isFinite(realEv)) {
return ''
}
if (realEv === 0) {
return 'T5'
}
if (realEv < 0) {
return 'T4'
}
if (realEv > 2) {
return 'T1'
}
if (realEv > 1 && realEv < 2) {
if (realEv > 1 && realEv <= 2) {
return 'T2'
}
if (realEv > 0 && realEv < 1) {
if (realEv > 0 && realEv <= 1) {
return 'T3'
}
if (realEv === 1) {
return 'T2'
if (realEv === 0) {
return 'T4'
}
if (realEv === 2) {
return 'T1'
if (realEv < 0) {
return 'T5'
}
return ''
}
@@ -101,16 +99,16 @@ export function validateTierRealEvStandards(s: TierRealEvStandards): string | nu
if (!Number.isFinite(s.T1) || !(s.T1 > 2)) {
return 'ruleGenInvalidT1RealEv'
}
if (!Number.isFinite(s.T2) || !(s.T2 > 1 && s.T2 < 2)) {
if (!Number.isFinite(s.T2) || !(s.T2 > 1 && s.T2 <= 2)) {
return 'ruleGenInvalidT2RealEv'
}
if (!Number.isFinite(s.T3) || !(s.T3 > 0 && s.T3 < 1)) {
if (!Number.isFinite(s.T3) || !(s.T3 > 0 && s.T3 <= 1)) {
return 'ruleGenInvalidT3RealEv'
}
if (!Number.isFinite(s.T4) || !(s.T4 < 0)) {
if (!Number.isFinite(s.T4) || s.T4 !== 0) {
return 'ruleGenInvalidT4RealEv'
}
if (!Number.isFinite(s.T5) || s.T5 !== 0) {
if (!Number.isFinite(s.T5) || s.T5 >= 0) {
return 'ruleGenInvalidT5RealEv'
}
return null
@@ -307,7 +305,7 @@ function uiTextByTierWhenStandards(
tier: IndexTier,
realEv: number
): { ui_text: string; ui_text_en: string } {
if (tier === 'T5') {
if (tier === 'T4') {
return { ui_text: '再来一次', ui_text_en: 'Once again' }
}
const value = Number.isFinite(realEv) ? realEv.toFixed(2) : String(realEv)
@@ -379,13 +377,13 @@ export function buildRowsFromTiers(
const f = uiTextByTierWhenStandards(tier, real_ev)
ui_text = f.ui_text
ui_text_en = f.ui_text_en
remark = '惩罚'
remark = '再来一次'
} else {
real_ev = standards.T5
const f = uiTextByTierWhenStandards(tier, real_ev)
ui_text = f.ui_text
ui_text_en = f.ui_text_en
remark = '前端需要在播放一次动画(特殊)'
remark = '惩罚'
}
} else if (tier === 'T1') {
real_ev = 101 + ((id * 17 + grid_number * 3) % 398)
@@ -417,18 +415,17 @@ export function buildRowsFromTiers(
ui_text_en = f.ui_text_en
remark = '抽水'
} else if (tier === 'T4') {
real_ev = 0
ui_text = '再来一次'
ui_text_en = 'Once again'
remark = '再来一次'
} else {
t4Seq++
real_ev = -101 - t4Seq * 15
const f = uiTextFromRealEv(real_ev)
ui_text = f.ui_text
ui_text_en = f.ui_text_en
remark = '惩罚'
} else {
real_ev = 0
const f = uiTextFromRealEv(real_ev)
ui_text = f.ui_text
ui_text_en = f.ui_text_en
remark = '前端需要在播放一次动画(特殊)'
}
rows.push({
id,

View File

@@ -263,6 +263,23 @@ class DiceRewardConfigController extends BaseController
}
}
/**
* 创建奖励对照(预览):不写入 dice_reward仅计算并返回预览分组数据。
* 若当前 dice_reward 与计算结果一致,则 unchanged=true并在预览中复用现有权重导入时仍沿用旧权重
*/
#[Permission('创建奖励对照', 'dice:reward_config:index:createRewardReference')]
public function createRewardReferencePreview(Request $request): Response
{
try {
$rewardLogic = new DiceRewardLogic();
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $request->input('dept_id'));
$result = $rewardLogic->createRewardReferencePreviewFromConfig($deptId);
return $this->success($result, 'preview reward mapping success');
} catch (\plugin\saiadmin\exception\ApiException $e) {
return $this->fail($e->getMessage());
}
}
/**
* 权重配比测试:仅模拟落点统计,不创建游玩记录。按当前配置在内存中模拟 N 次抽奖,返回各 grid_number 落点次数,可选保存到 dice_reward_config_record。
* @param Request $request test_count: 100|500|1000, save_record: bool, lottery_config_id: int|null 奖池配置ID用于设定 T1-T5 概率

View File

@@ -327,6 +327,79 @@ class DiceRewardLogic
private const GRID_NUMBER_MIN = 5;
private const GRID_NUMBER_MAX = 30;
/**
* 预览:按当前 dice_reward_config 计算将要生成的 dice_reward不写库
* 若当前 dice_reward 与计算结果完全一致,则标记 unchanged=true并返回现有权重导入时将复用旧权重
*
* @return array{unchanged: bool, skipped: int, preview: array<string, array{0: array, 1: array}>}
* @throws ApiException
*/
public function createRewardReferencePreviewFromConfig(?int $deptId = null): array
{
$normalizedDeptId = $deptId;
$list = $this->loadConfigListForReference($normalizedDeptId);
$computed = $this->computeReferenceRowsFromConfigList($list, $normalizedDeptId);
$existing = $this->loadExistingRewardRowsForReference($normalizedDeptId);
$compare = $this->compareReferenceRows($computed['rows'], $existing);
$unchanged = $compare['unchanged'];
$previewRows = [];
foreach ($computed['rows'] as $row) {
$key = $row['direction'] . ':' . $row['grid_number'];
$weight = self::WEIGHT_MIN;
$oldStart = null;
$oldEnd = null;
$oldTier = null;
$oldWeight = null;
if (isset($existing[$key])) {
$oldStart = isset($existing[$key]['start_index']) ? (int) $existing[$key]['start_index'] : null;
$oldEnd = isset($existing[$key]['end_index']) ? (int) $existing[$key]['end_index'] : null;
$oldTier = isset($existing[$key]['tier']) ? (string) $existing[$key]['tier'] : null;
$oldWeight = isset($existing[$key]['weight']) ? (int) $existing[$key]['weight'] : null;
}
if ($unchanged && $oldWeight !== null) {
$weight = max(self::WEIGHT_MIN, min(self::WEIGHT_MAX, (int) $oldWeight));
}
$diffChanged = false;
$diffFields = [];
if ($oldStart === null || $oldEnd === null || $oldTier === null) {
$diffChanged = true;
$diffFields[] = 'new';
} else {
if ((int) $oldStart !== (int) ($row['start_index'] ?? 0)) {
$diffChanged = true;
$diffFields[] = 'start_index';
}
if ((int) $oldEnd !== (int) ($row['end_index'] ?? 0)) {
$diffChanged = true;
$diffFields[] = 'end_index';
}
if (trim((string) $oldTier) !== trim((string) ($row['tier'] ?? ''))) {
$diffChanged = true;
$diffFields[] = 'tier';
}
}
$previewRows[] = array_merge($row, [
'weight' => $weight,
'old_start_index' => $oldStart,
'old_end_index' => $oldEnd,
'old_tier' => $oldTier,
'old_weight' => $oldWeight,
'diff_changed' => $diffChanged,
'diff_fields' => $diffFields,
]);
}
return [
'unchanged' => $unchanged,
'skipped' => $computed['skipped'],
'preview' => $this->groupReferenceRowsByTierWithDirection($previewRows),
];
}
/**
* 创建奖励对照:先清空 dice_reward 表,再按两种方向为点数 5-30 生成记录。
*
@@ -352,10 +425,76 @@ class DiceRewardLogic
* @throws ApiException
*/
public function createRewardReferenceFromConfig(?int $deptId = null): array
{
$normalizedDeptId = $deptId;
$list = $this->loadConfigListForReference($normalizedDeptId);
$computed = $this->computeReferenceRowsFromConfigList($list, $normalizedDeptId);
$existing = $this->loadExistingRewardRowsForReference($normalizedDeptId);
$compare = $this->compareReferenceRows($computed['rows'], $existing);
if ($compare['unchanged']) {
return [
'created_clockwise' => 0,
'created_counterclockwise' => 0,
'updated_clockwise' => 0,
'updated_counterclockwise' => 0,
'skipped' => $computed['skipped'],
'unchanged' => true,
];
}
$table = (new DiceReward())->getTable();
if ($normalizedDeptId === null) {
Db::table($table)->whereNull('dept_id')->delete();
} else {
Db::table($table)->where('dept_id', $normalizedDeptId)->delete();
}
$createdCw = 0;
$createdCcw = 0;
foreach ($computed['rows'] as $row) {
$m = new DiceReward();
$m->tier = $row['tier'];
$m->direction = (int) $row['direction'];
$m->end_index = (int) $row['end_index'];
$m->weight = self::WEIGHT_MIN;
$m->grid_number = (int) $row['grid_number'];
$m->start_index = (int) $row['start_index'];
$m->ui_text = (string) ($row['ui_text'] ?? '');
$m->real_ev = $row['real_ev'] ?? null;
$m->remark = (string) ($row['remark'] ?? '');
$m->type = isset($row['type']) ? (int) $row['type'] : 0;
if ($normalizedDeptId !== null) {
$m->dept_id = $normalizedDeptId;
}
$m->save();
if ((int) $row['direction'] === DiceReward::DIRECTION_CLOCKWISE) {
$createdCw++;
} else {
$createdCcw++;
}
}
DiceReward::refreshCache($normalizedDeptId ?? AdminScopeHelper::DEFAULT_TEMPLATE_DEPT);
return [
'created_clockwise' => $createdCw,
'created_counterclockwise' => $createdCcw,
'updated_clockwise' => 0,
'updated_counterclockwise' => 0,
'skipped' => $computed['skipped'],
'unchanged' => false,
];
}
/**
* 读取奖励配置(按 id asc并把模板 dept 转为 null
* @return array<int, array<string, mixed>>
*/
private function loadConfigListForReference(?int &$deptId): array
{
$configQuery = DiceRewardConfig::order('id', 'asc');
if ($deptId === null || $deptId === \app\dice\helper\AdminScopeHelper::DEFAULT_TEMPLATE_DEPT) {
$templateId = \app\dice\helper\AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
if ($deptId === null || $deptId === AdminScopeHelper::DEFAULT_TEMPLATE_DEPT) {
$templateId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
$configQuery->where(function ($q) use ($templateId) {
$q->where('dept_id', $templateId)->whereOr('dept_id', 'null');
});
@@ -367,25 +506,25 @@ class DiceRewardLogic
if (empty($list)) {
throw new ApiException('Reward config is empty, please maintain dice_reward_config first');
}
$configCount = count($list);
if ($configCount < self::BOARD_SIZE) {
if (count($list) < self::BOARD_SIZE) {
throw new ApiException(
\app\api\util\ApiLang::translateParams(
'奖励配置需覆盖 26 个格位id 0-25 或 1-26当前仅 %s 条,无法完整生成 5-30 共26个点数、顺时针与逆时针的奖励对照',
[$configCount]
[count($list)]
)
);
}
return $list;
}
$table = (new DiceReward())->getTable();
if ($deptId === null) {
Db::table($table)->whereNull('dept_id')->delete();
} else {
Db::table($table)->where('dept_id', $deptId)->delete();
}
DiceReward::refreshCache($deptId ?? AdminScopeHelper::DEFAULT_TEMPLATE_DEPT);
// 按 id 排序后,盘面位置 0..25 对应 $list[$pos],避免 config.id 非 0-25/1-26 时取模结果找不到
/**
* 计算 5-30 两个方向的对照行(不含权重)
* @param array<int, array<string, mixed>> $list
* @return array{rows: array<int, array<string, mixed>>, skipped: int}
*/
private function computeReferenceRowsFromConfigList(array $list, ?int $deptId): array
{
// 按 id 排序后,盘面位置 0..25 对应 $list[$pos]
$gridToPosition = [];
foreach ($list as $pos => $row) {
$gn = isset($row['grid_number']) ? (int) $row['grid_number'] : 0;
@@ -394,12 +533,8 @@ class DiceRewardLogic
}
}
$createdCw = 0;
$createdCcw = 0;
$updatedCw = 0;
$updatedCcw = 0;
$rows = [];
$skipped = 0;
for ($gridNumber = self::GRID_NUMBER_MIN; $gridNumber <= self::GRID_NUMBER_MAX; $gridNumber++) {
if (!isset($gridToPosition[$gridNumber])) {
$skipped++;
@@ -414,115 +549,131 @@ class DiceRewardLogic
$configCw = $list[$endPosCw] ?? null;
$configCcw = $list[$endPosCcw] ?? null;
$endIdCw = $configCw !== null && isset($configCw['id']) ? (int) $configCw['id'] : 0;
$endIdCcw = $configCcw !== null && isset($configCcw['id']) ? (int) $configCcw['id'] : 0;
if ($configCw !== null) {
$tier = isset($configCw['tier']) ? trim((string) $configCw['tier']) : '';
if ($tier !== '') {
// 使用对应奖励配置的 weight 作为格子权重(若未配置则退回最小权重)
$weightCw = isset($configCw['weight']) && $configCw['weight'] !== null
? $configCw['weight']
: self::WEIGHT_MIN;
$payloadCw = [
$rows[] = [
'tier' => $tier,
'weight' => $weightCw,
'direction' => DiceReward::DIRECTION_CLOCKWISE,
'weight' => self::WEIGHT_MIN,
'grid_number' => $gridNumber,
'start_index' => $startId,
'end_index' => $endIdCw,
'end_index' => isset($configCw['id']) ? (int) $configCw['id'] : 0,
'ui_text' => $configCw['ui_text'] ?? '',
'real_ev' => $configCw['real_ev'] ?? null,
'remark' => $configCw['remark'] ?? '',
'type' => isset($configCw['type']) ? (int) $configCw['type'] : 0,
];
$existingQuery = DiceReward::where('direction', DiceReward::DIRECTION_CLOCKWISE)->where('grid_number', $gridNumber);
if ($deptId === null) {
$existingQuery->whereNull('dept_id');
} else {
$existingQuery->where('dept_id', $deptId);
}
$existing = $existingQuery->find();
if ($existing) {
DiceReward::where('id', $existing->id)->update($payloadCw);
$updatedCw++;
} else {
$m = new DiceReward();
$m->tier = $tier;
$m->direction = DiceReward::DIRECTION_CLOCKWISE;
$m->end_index = $endIdCw;
$m->weight = $weightCw;
$m->grid_number = $gridNumber;
$m->start_index = $startId;
$m->ui_text = $configCw['ui_text'] ?? '';
$m->real_ev = $configCw['real_ev'] ?? null;
$m->remark = $configCw['remark'] ?? '';
$m->type = isset($configCw['type']) ? (int) $configCw['type'] : 0;
if ($deptId !== null) {
$m->dept_id = $deptId;
}
$m->save();
$createdCw++;
}
}
}
if ($configCcw !== null) {
$tier = isset($configCcw['tier']) ? trim((string) $configCcw['tier']) : '';
if ($tier !== '') {
// 使用对应奖励配置的 weight 作为格子权重(若未配置则退回最小权重)
$weightCcw = isset($configCcw['weight']) && $configCcw['weight'] !== null
? $configCcw['weight']
: self::WEIGHT_MIN;
$payloadCcw = [
$rows[] = [
'tier' => $tier,
'weight' => $weightCcw,
'direction' => DiceReward::DIRECTION_COUNTERCLOCKWISE,
'weight' => self::WEIGHT_MIN,
'grid_number' => $gridNumber,
'start_index' => $startId,
'end_index' => $endIdCcw,
'end_index' => isset($configCcw['id']) ? (int) $configCcw['id'] : 0,
'ui_text' => $configCcw['ui_text'] ?? '',
'real_ev' => $configCcw['real_ev'] ?? null,
'remark' => $configCcw['remark'] ?? '',
'type' => isset($configCcw['type']) ? (int) $configCcw['type'] : 0,
];
$existingQuery = DiceReward::where('direction', DiceReward::DIRECTION_COUNTERCLOCKWISE)->where('grid_number', $gridNumber);
if ($deptId === null) {
$existingQuery->whereNull('dept_id');
} else {
$existingQuery->where('dept_id', $deptId);
}
$existing = $existingQuery->find();
if ($existing) {
DiceReward::where('id', $existing->id)->update($payloadCcw);
$updatedCcw++;
} else {
$m = new DiceReward();
$m->tier = $tier;
$m->direction = DiceReward::DIRECTION_COUNTERCLOCKWISE;
$m->end_index = $endIdCcw;
$m->weight = $weightCcw;
$m->grid_number = $gridNumber;
$m->start_index = $startId;
$m->ui_text = $configCcw['ui_text'] ?? '';
$m->real_ev = $configCcw['real_ev'] ?? null;
$m->remark = $configCcw['remark'] ?? '';
$m->type = isset($configCcw['type']) ? (int) $configCcw['type'] : 0;
if ($deptId !== null) {
$m->dept_id = $deptId;
}
$m->save();
$createdCcw++;
}
}
}
}
return ['rows' => $rows, 'skipped' => $skipped];
}
DiceReward::refreshCache($deptId ?? AdminScopeHelper::DEFAULT_TEMPLATE_DEPT);
return [
'created_clockwise' => $createdCw,
'created_counterclockwise' => $createdCcw,
'updated_clockwise' => $updatedCw,
'updated_counterclockwise' => $updatedCcw,
'skipped' => $skipped,
];
/**
* 读出当前 dice_reward用于对比/复用权重。key = "direction:grid_number"
* @return array<string, array<string, mixed>>
*/
private function loadExistingRewardRowsForReference(?int $deptId): array
{
$query = DiceReward::whereIn('grid_number', range(self::GRID_NUMBER_MIN, self::GRID_NUMBER_MAX))
->whereIn('direction', [DiceReward::DIRECTION_CLOCKWISE, DiceReward::DIRECTION_COUNTERCLOCKWISE]);
if ($deptId === null) {
$query->whereNull('dept_id');
} else {
$query->where('dept_id', $deptId);
}
$rows = $query->select()->toArray();
$map = [];
foreach ($rows as $r) {
$dir = isset($r['direction']) ? (int) $r['direction'] : 0;
$gn = isset($r['grid_number']) ? (int) $r['grid_number'] : 0;
$map[$dir . ':' . $gn] = $r;
}
return $map;
}
/**
* 对比 computed 与 existing 是否完全一致(忽略权重)
* @param array<int, array<string, mixed>> $computedRows
* @param array<string, array<string, mixed>> $existingMap
* @return array{unchanged: bool}
*/
private function compareReferenceRows(array $computedRows, array $existingMap): array
{
if (empty($computedRows)) {
return ['unchanged' => false];
}
foreach ($computedRows as $row) {
$key = $row['direction'] . ':' . $row['grid_number'];
if (!isset($existingMap[$key])) {
return ['unchanged' => false];
}
$ex = $existingMap[$key];
$same =
((int) ($ex['start_index'] ?? 0) === (int) ($row['start_index'] ?? 0)) &&
((int) ($ex['end_index'] ?? 0) === (int) ($row['end_index'] ?? 0)) &&
(trim((string) ($ex['tier'] ?? '')) === trim((string) ($row['tier'] ?? '')));
if (!$same) {
return ['unchanged' => false];
}
}
return ['unchanged' => true];
}
/**
* 将行按 tier -> {0:[],1:[]} 组织,便于前端展示(与 weightRatioList 输出结构一致)
* @param array<int, array<string, mixed>> $rows
* @return array<string, array{0: array, 1: array}>
*/
private function groupReferenceRowsByTierWithDirection(array $rows): array
{
$result = [];
foreach (self::TIER_KEYS as $tier) {
$result[$tier] = [0 => [], 1 => []];
}
foreach ($rows as $r) {
$tier = isset($r['tier']) ? trim((string) $r['tier']) : '';
if ($tier === '' || !isset($result[$tier])) {
continue;
}
$dir = isset($r['direction']) ? (int) $r['direction'] : 0;
$dir = $dir === 1 ? 1 : 0;
$result[$tier][$dir][] = [
'reward_id' => 0,
'id' => isset($r['end_index']) ? (int) $r['end_index'] : 0,
'grid_number' => isset($r['grid_number']) ? (int) $r['grid_number'] : 0,
'ui_text' => (string) ($r['ui_text'] ?? ''),
'real_ev' => $r['real_ev'] ?? 0,
'remark' => (string) ($r['remark'] ?? ''),
'weight' => isset($r['weight']) ? max(self::WEIGHT_MIN, min(self::WEIGHT_MAX, (int) $r['weight'])) : self::WEIGHT_MIN,
'start_index' => isset($r['start_index']) ? (int) $r['start_index'] : 0,
'tier' => (string) ($r['tier'] ?? ''),
'old_start_index' => $r['old_start_index'] ?? null,
'old_end_index' => $r['old_end_index'] ?? null,
'old_tier' => $r['old_tier'] ?? null,
'old_weight' => $r['old_weight'] ?? null,
'diff_changed' => (bool) ($r['diff_changed'] ?? false),
'diff_fields' => $r['diff_fields'] ?? [],
];
}
return $result;
}
}

View File

@@ -83,7 +83,7 @@
其中地图的索引可以按照需求点击图中的按规则生成
并且规则尽可能符合:结算金额 < 0 → T40 < 结算金额 < 100 → T3100 < 结算金额 < 200 → T2200 < 结算金额 → T1T5「再来一次」结算金额=0
并且规则尽可能符合:结算金额>2 → T12>=结算金额>1 → T21>=结算金额>0 → T3结算金额=0 → T4再来一次0>结算金额 → T5惩罚
![image.png](/docs/picture/guide_16.png)

View File

@@ -121,6 +121,7 @@ Route::group('/core', function () {
Route::post('/dice/reward_config/DiceRewardConfig/batchUpdate', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'batchUpdate']);
Route::post('/dice/reward_config/DiceRewardConfig/generateIndexByRules', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'generateIndexByRules']);
Route::post('/dice/reward_config/DiceRewardConfig/createRewardReference', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'createRewardReference']);
Route::post('/dice/reward_config/DiceRewardConfig/createRewardReferencePreview', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'createRewardReferencePreview']);
Route::post('/dice/reward_config/DiceRewardConfig/runWeightTest', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'runWeightTest']);
fastRoute('dice/game/DiceGame', \app\dice\controller\game\DiceGameController::class);
fastRoute('dice/ante_config/DiceAnteConfig', \app\dice\controller\ante_config\DiceAnteConfigController::class);