Compare commits

...

3 Commits

Author SHA1 Message Date
77ec0dcade 优化页面不显示小数 2026-03-26 14:31:32 +08:00
d72a8487a8 项目介绍-优化 2026-03-26 14:25:45 +08:00
7596007a5a 项目介绍 2026-03-26 14:19:35 +08:00
9 changed files with 265 additions and 39 deletions

View File

@@ -71,6 +71,16 @@
{{ row.direction === 0 ? t('page.search.clockwise') : row.direction === 1 ? t('page.search.anticlockwise') : '-' }} {{ row.direction === 0 ? t('page.search.clockwise') : row.direction === 1 ? t('page.search.anticlockwise') : '-' }}
</ElTag> </ElTag>
</template> </template>
<!-- 平台币相关统一整数显示 -->
<template #win_coin="{ row }">
<span>{{ formatPlatformCoin(row?.win_coin) }}</span>
</template>
<template #super_win_coin="{ row }">
<span>{{ formatPlatformCoin(row?.super_win_coin) }}</span>
</template>
<template #reward_win_coin="{ row }">
<span>{{ formatPlatformCoin(row?.reward_win_coin) }}</span>
</template>
<!-- 摇取点数 tag --> <!-- 摇取点数 tag -->
<template #roll_array="{ row }"> <template #roll_array="{ row }">
<ElTag size="small"> <ElTag size="small">
@@ -166,6 +176,13 @@
return String(val) return String(val)
} }
function formatPlatformCoin(val: unknown): string {
if (val === '' || val === null || val === undefined) return '-'
const n = typeof val === 'number' ? val : Number(val)
if (!Number.isFinite(n)) return '-'
return String(Math.trunc(n))
}
// 表格配置 // 表格配置
const { const {
columns, columns,
@@ -202,9 +219,9 @@
{ prop: 'ante', label: 'page.table.ante', width: 80, align: 'center' }, { prop: 'ante', label: 'page.table.ante', width: 80, align: 'center' },
{ prop: 'paid_amount', label: 'page.table.paidAmount', width: 110, align: 'center' }, { prop: 'paid_amount', label: 'page.table.paidAmount', width: 110, align: 'center' },
{ prop: 'is_win', label: 'page.table.isBigWin', width: 100, useSlot: true }, { prop: 'is_win', label: 'page.table.isBigWin', width: 100, useSlot: true },
{ prop: 'win_coin', label: 'page.table.winCoin', width: 110 }, { prop: 'win_coin', label: 'page.table.winCoin', width: 110, useSlot: true },
{ prop: 'super_win_coin', label: 'page.table.superWinCoin', width: 120 }, { prop: 'super_win_coin', label: 'page.table.superWinCoin', width: 120, useSlot: true },
{ prop: 'reward_win_coin', label: 'page.table.rewardWinCoin', width: 140 }, { prop: 'reward_win_coin', label: 'page.table.rewardWinCoin', width: 140, useSlot: true },
{ prop: 'direction', label: 'page.table.direction', width: 90, useSlot: true }, { prop: 'direction', label: 'page.table.direction', width: 90, useSlot: true },
{ prop: 'start_index', label: 'page.table.startIndex', width: 90 }, { prop: 'start_index', label: 'page.table.startIndex', width: 90 },
{ prop: 'target_index', label: 'page.table.targetIndex', width: 90 }, { prop: 'target_index', label: 'page.table.targetIndex', width: 90 },

View File

@@ -70,7 +70,7 @@
<el-input-number <el-input-number
v-model="formData.win_coin" v-model="formData.win_coin"
:placeholder="$t('page.form.placeholderWinCoin')" :placeholder="$t('page.form.placeholderWinCoin')"
:precision="2" :precision="0"
style="width: 100%" style="width: 100%"
:disabled="dialogType === 'edit'" :disabled="dialogType === 'edit'"
/> />
@@ -79,7 +79,7 @@
<el-input-number <el-input-number
v-model="formData.super_win_coin" v-model="formData.super_win_coin"
:placeholder="$t('page.form.placeholderSuperWinCoin')" :placeholder="$t('page.form.placeholderSuperWinCoin')"
:precision="2" :precision="0"
:min="0" :min="0"
style="width: 100%" style="width: 100%"
:disabled="dialogType === 'edit'" :disabled="dialogType === 'edit'"
@@ -89,7 +89,7 @@
<el-input-number <el-input-number
v-model="formData.reward_win_coin" v-model="formData.reward_win_coin"
:placeholder="$t('page.form.placeholderRewardWinCoin')" :placeholder="$t('page.form.placeholderRewardWinCoin')"
:precision="2" :precision="0"
:min="0" :min="0"
style="width: 100%" style="width: 100%"
:disabled="dialogType === 'edit'" :disabled="dialogType === 'edit'"

View File

@@ -48,7 +48,7 @@
<el-input-number <el-input-number
v-model="formData.win_coin_min" v-model="formData.win_coin_min"
:placeholder="$t('table.searchBar.min')" :placeholder="$t('table.searchBar.min')"
:precision="2" :precision="0"
controls-position="right" controls-position="right"
class="range-input" class="range-input"
/> />
@@ -56,7 +56,7 @@
<el-input-number <el-input-number
v-model="formData.win_coin_max" v-model="formData.win_coin_max"
:placeholder="$t('table.searchBar.max')" :placeholder="$t('table.searchBar.max')"
:precision="2" :precision="0"
controls-position="right" controls-position="right"
class="range-input" class="range-input"
/> />

View File

@@ -146,6 +146,13 @@
return player?.username ?? row.player_id ?? '-' return player?.username ?? row.player_id ?? '-'
} }
function formatInteger(val: unknown): string {
if (val === '' || val === null || val === undefined) return '-'
const n = typeof val === 'number' ? val : Number(val)
if (!Number.isFinite(n)) return '-'
return String(Math.trunc(n))
}
// 表格配置 // 表格配置
const { const {
columns, columns,
@@ -190,8 +197,20 @@
align: 'center', align: 'center',
formatter: operatorFormatter formatter: operatorFormatter
}, },
{ prop: 'wallet_before', label: 'page.table.walletBefore', width: 110, align: 'center' }, {
{ prop: 'wallet_after', label: 'page.table.walletAfter', width: 110, align: 'center' }, prop: 'wallet_before',
label: 'page.table.walletBefore',
width: 110,
align: 'center',
formatter: (row: Record<string, any>) => formatInteger(row?.wallet_before)
},
{
prop: 'wallet_after',
label: 'page.table.walletAfter',
width: 110,
align: 'center',
formatter: (row: Record<string, any>) => formatInteger(row?.wallet_after)
},
{ {
prop: 'remark', prop: 'remark',
label: 'page.table.remark', label: 'page.table.remark',

View File

@@ -45,7 +45,7 @@
<el-input-number <el-input-number
v-model="formData.coin" v-model="formData.coin"
:placeholder="$t('page.form.placeholderCoinChange')" :placeholder="$t('page.form.placeholderCoinChange')"
:precision="2" :precision="0"
style="width: 100%" style="width: 100%"
@change="onCoinChange" @change="onCoinChange"
:disabled="dialogType === 'edit'" :disabled="dialogType === 'edit'"
@@ -55,7 +55,7 @@
<el-input-number <el-input-number
v-model="formData.wallet_before" v-model="formData.wallet_before"
:placeholder="$t('page.form.placeholderWalletBefore')" :placeholder="$t('page.form.placeholderWalletBefore')"
:precision="2" :precision="0"
disabled disabled
style="width: 100%" style="width: 100%"
/> />
@@ -64,7 +64,7 @@
<el-input-number <el-input-number
v-model="formData.wallet_after" v-model="formData.wallet_after"
:placeholder="$t('page.form.placeholderWalletAfter')" :placeholder="$t('page.form.placeholderWalletAfter')"
:precision="2" :precision="0"
disabled disabled
style="width: 100%" style="width: 100%"
/> />
@@ -131,14 +131,22 @@
type: [{ required: true, message: '请选择类型', trigger: 'change' }] type: [{ required: true, message: '请选择类型', trigger: 'change' }]
}) })
const initialFormData = { const initialFormData: {
id: null as number | null, id: number | null
player_id: null as number | null, player_id: number | null
coin: 0 as number, coin: number
type: null as number | null, type: number | null
wallet_before: 0 as number, wallet_before: number
wallet_after: 0 as number, wallet_after: number
remark: '' as string remark: string
} = {
id: null,
player_id: null,
coin: 0,
type: null,
wallet_before: 0,
wallet_after: 0,
remark: ''
} }
const formData = reactive({ ...initialFormData }) const formData = reactive({ ...initialFormData })
@@ -170,7 +178,7 @@
function calcWalletAfter() { function calcWalletAfter() {
const before = Number(formData.wallet_before) || 0 const before = Number(formData.wallet_before) || 0
const coin = Number(formData.coin) || 0 const coin = Number(formData.coin) || 0
formData.wallet_after = Math.round((before + coin) * 100) / 100 formData.wallet_after = Math.trunc(before + coin)
} }
watch( watch(
@@ -196,23 +204,24 @@
} }
} }
const numKeys = ['id', 'player_id', 'coin', 'type', 'wallet_before', 'wallet_after'] function normalizeInteger(val: unknown, fallback: number): number {
if (val === '' || val === null || val === undefined) return fallback
const n = typeof val === 'number' ? val : Number(val)
if (!Number.isFinite(n)) return fallback
return Math.trunc(n)
}
const initForm = () => { const initForm = () => {
if (!props.data) return if (!props.data) return
for (const key of Object.keys(formData)) {
if (!(key in props.data)) continue formData.id = props.data.id != null && props.data.id !== '' ? Number(props.data.id) : null
const val = props.data[key] formData.player_id =
if (numKeys.includes(key)) { props.data.player_id != null && props.data.player_id !== '' ? Number(props.data.player_id) : null
if (key === 'id' || key === 'player_id' || key === 'type') { formData.type = props.data.type != null && props.data.type !== '' ? Number(props.data.type) : null
;(formData as any)[key] = val != null && val !== '' ? Number(val) : null formData.coin = normalizeInteger(props.data.coin, 0)
} else { formData.wallet_before = normalizeInteger(props.data.wallet_before, 0)
;(formData as any)[key] = val != null && val !== '' ? Number(val) : 0 formData.wallet_after = normalizeInteger(props.data.wallet_after, 0)
} formData.remark = props.data.remark ?? ''
} else {
;(formData as any)[key] = val ?? ''
}
}
} }
const handleClose = () => { const handleClose = () => {

View File

@@ -70,6 +70,13 @@
return api.list({ ...params, direction: currentDirection.value }) return api.list({ ...params, direction: currentDirection.value })
} }
function formatInteger(val: unknown): string {
if (val === '' || val === null || val === undefined) return '-'
const n = typeof val === 'number' ? val : Number(val)
if (!Number.isFinite(n)) return '-'
return String(Math.trunc(n))
}
const handleSearch = (params: Record<string, any>) => { const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, { ...params, direction: currentDirection.value }) Object.assign(searchParams, { ...params, direction: currentDirection.value })
getData() getData()
@@ -117,7 +124,13 @@
align: 'center', align: 'center',
showOverflowTooltip: true showOverflowTooltip: true
}, },
{ prop: 'real_ev', label: 'page.table.realEv', width: 110, align: 'center' }, {
prop: 'real_ev',
label: 'page.table.realEv',
width: 110,
align: 'center',
formatter: (row: Record<string, any>) => formatInteger(row?.real_ev)
},
{ prop: 'remark', label: 'page.table.remark', minWidth: 80, align: 'center', showOverflowTooltip: true }, { prop: 'remark', label: 'page.table.remark', minWidth: 80, align: 'center', showOverflowTooltip: true },
{ prop: 'weight', label: 'page.table.weight', width: 110, align: 'center' } { prop: 'weight', label: 'page.table.weight', width: 110, align: 'center' }
] ]

View File

@@ -60,7 +60,11 @@
width="90" width="90"
align="center" align="center"
show-overflow-tooltip show-overflow-tooltip
/> >
<template #default="{ row }">
<span>{{ formatInteger(row?.real_ev) }}</span>
</template>
</el-table-column>
<el-table-column <el-table-column
:label="$t('page.weightShared.colUiText')" :label="$t('page.weightShared.colUiText')"
prop="ui_text" prop="ui_text"
@@ -250,6 +254,13 @@
import api from '../../../api/reward/index' import api from '../../../api/reward/index'
import ArtBarChart from '@/components/core/charts/art-bar-chart/index.vue' import ArtBarChart from '@/components/core/charts/art-bar-chart/index.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
function formatInteger(val: unknown): string {
if (val === '' || val === null || val === undefined) return '-'
const n = typeof val === 'number' ? val : Number(val)
if (!Number.isFinite(n)) return '-'
return String(Math.trunc(n))
}
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t } = useI18n()

View File

@@ -54,7 +54,11 @@
width="90" width="90"
align="center" align="center"
show-overflow-tooltip show-overflow-tooltip
/> >
<template #default="{ row }">
<span>{{ formatInteger(row?.real_ev) }}</span>
</template>
</el-table-column>
<el-table-column <el-table-column
:label="$t('page.weightShared.colUiText')" :label="$t('page.weightShared.colUiText')"
prop="ui_text" prop="ui_text"
@@ -315,6 +319,13 @@
import ArtBarChart from '@/components/core/charts/art-bar-chart/index.vue' import ArtBarChart from '@/components/core/charts/art-bar-chart/index.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
function formatInteger(val: unknown): string {
if (val === '' || val === null || val === undefined) return '-'
const n = typeof val === 'number' ? val : Number(val)
if (!Number.isFinite(n)) return '-'
return String(Math.trunc(n))
}
const { t, locale } = useI18n() const { t, locale } = useI18n()

146
项目文档.md Normal file
View File

@@ -0,0 +1,146 @@
# 大富翁 · 摇色子 — 项目文档
本文描述**业务玩法**与**服务端抽奖/结算机制**,便于产品、运营与二次开发对齐实现。接口路径、鉴权与联调细节见根目录 [`API对接文档.md`](API对接文档.md)。
---
## 1. 项目概述
- **形态**:平台玩家使用「平台币」参与摇五颗标准六面骰(点数各 16结果对应棋盘/奖励配置;后台可配置档位权重、奖池、杀分策略与展示文案(含中英文)。
- **服务端**PHP [Webman](https://www.workerman.net/webman)`server/`),玩家与平台接口在 `app/api`;骰子业务模型在 `app/dice`
- **管理端**:前端工程 `saiadmin-artd/`(与 SaiAdmin 插件体系配套)。
---
## 2. 核心概念
| 概念 | 说明 |
| --- | --- |
| **平台币 `coin`** | 玩家钱包余额;付费开局、购买套餐、中奖结算均围绕该字段。 |
| **注数 `ante`** | 倍数因子,须存在于表 `dice_ante_config``mult` 中;接口 `/api/game/anteConfig` 返回可选注数。 |
| **单注费用** | 付费抽奖时,开局前扣除 **`ante × 100`** 平台币(代码常量 `UNIT_COST = 100`,即「单注 100 币」口径)。 |
| **方向 `direction`** | 开局参数:`0``1` 对应两套奖励数据(顺时针/逆时针或「无 / 中奖」分支,由前端与配置表共同约定);服务端在 **档位确定后**,按当前方向从 `DiceReward` 缓存结构中取该档位下的条目再按权重抽取。 |
| **档位 T1T5** | 中奖层级;先抽档位,再在该档位 + 当前方向下按 `weight` 抽一条奖励配置。 |
| **`grid_number`530** | 与「五颗骰子点数之和」一致:最小 5全 1最大 30全 6用于关联奖励行与后续生成 `roll_array`。 |
| **`real_ev`** | 奖励配置中的期望调节项;**普通中奖**结算为 **`(100 + real_ev) × ante`**(付费局在开局已扣 `ante×100`,净效果依 `real_ev` 而定)。 |
---
## 3. 玩法流程(玩家视角)
1. **登录 / 进游戏**
平台侧通过 `/api/v1/getGameUrl` 或玩家侧 `/api/user/Login` 换取 token打开前端页面。
2. **(可选)购买「抽奖券」套餐**
`POST /api/game/buyLotteryTickets``count` 仅支持 `1``5``10`
- 1100 币 → 1 次付费计数 + 0 次赠送
- 5500 币 → 5 次付费 + **1 次赠送**(共 6 次计入总次数)
- 101000 币 → 10 次付费 + **3 次赠送**(共 13 次)
会更新玩家身上的 `total_ticket_count` / `paid_ticket_count` / `free_ticket_count`,并记钱包与券流水。
3. **开局抽奖**
`POST /api/game/playStart`,需传 **`direction`0 或 1** 与 **`ante`(正整数,且须在底注配置中)**。
4. **付费 vs 免费**
- **免费抽奖**:当 `free_ticket_count > 0` 时,本局视为免费类型:不扣 `ante×100`,但会消耗 **1 次** `free_ticket_count`
- **付费抽奖**:不依赖「券张数是否大于 0」只要非免费局开局前扣 **`ante × 100`**。
> **重要**:当前实现已**不再用「抽奖券张数」作为能否开局的条件**`buyLotteryTickets` 更新的是统计与赠送次数,**真正开局仍看余额、注数、免费次数等规则**(见下节)。
5. **免费注数锁定**
若上一局因命中 **T5** 赠送了免费次数,服务端会缓存「免费局须与触发时相同的 `ante`」,不一致则拒绝并提示修改注数。
---
## 4. 抽奖与结算机制(服务端逻辑)
以下对应 `PlayStartLogic``LotteryService`,便于理解「先抽什么、再算什么钱」。
### 4.1 前置校验
- 用户存在;`ante` 合法。
- **最低余额**`coin ≥ abs(min_real_ev) × ante``min_real_ev` 来自全表 `DiceRewardConfig` 缓存),防止极端负 EV 下余额不足以覆盖风险口径。
- 付费局:`coin ≥ ante × 100`
### 4.2 使用哪套「档位权重」:默认奖池 vs 杀分奖池
配置表 `dice_lottery_pool_config` 至少要有 **`name = default`**;可选 **`name = killScore`**。
- **`default` 彩金池**维护累计盈利字段 **`profit_amount`**(见 4.5)。
- 记:`safety_line` = 安全线,`kill_enabled` = 是否开启杀分。
**是否按「奖池档位权重」抽档位(`usePoolWeights`**
| 情形 | 档位权重来源 |
| --- | --- |
| **免费局** | 使用 **killScore** 奖池的 T1T5 权重;若无 `killScore` 则退回 `default`。 |
| **付费局****杀分开启****`profit_amount ≥ safety_line`** 且 **存在 killScore** | 使用 **killScore** 的档位权重(杀分模式)。 |
| **其他付费局** | 使用 **玩家**身上的 `t1_weight``t5_weight``DicePlayer` 字段,与 `LotteryService::drawTierByPlayerWeights` 一致)。 |
档位抽出 **T1T5** 后,从 `DiceReward` 缓存中取出 **`[该档位][direction]`** 下的所有奖励行,再按行 **`weight`** 做加权随机(仅 `weight > 0` 参与;全为 0 会重试档位,最多约 10 次)。
### 4.3 杀分模式下的特殊处理
当使用 **killScore / 免费局** 等与杀分一致的权重路径时:
- 在奖励抽取阶段会 **排除 `grid_number` 为 5 和 30 的配置**(这两点数和只能对应「全 1」「全 6」豹子无法做成非豹子展示
- **不会触发豹子大奖**(见 4.4):若摇到豹子点数组,只生成 **非豹子** 的五骰组合,不发放豹子附加奖金。
### 4.4 普通奖与「豹子 / BIGWIN」
- 若本次抽中的 `grid_number` **不是**「豹子集合」`{5,10,15,20,25,30}`:按点数和生成 5 个 16 的骰子(和为 `grid_number`**普通奖金** = **`(100 + real_ev) × ante`**(付费局已预先扣除 `ante×100`)。
- 若点数和落在豹子集合:
- **`grid_number` 为 5 或 30**:若**非**杀分路径,**必定**按豹子结算(五颗相同点数)。
- **10 / 15 / 20 / 25**:读取 `DiceRewardConfig`**`tier = BIGWIN`** 且对应该 `grid_number` 的配置,用其 **`weight`01000010000=100%** 随机决定是否视为真豹子;否则生成**非豹子**但点数和不变的骰子组合。
- **真豹子**时:奖金按 **`(100 + big_win_real_ev) × ante`** 发放(`big_win_real_ev` 来自 BIGWIN 配置;若未配则用代码兜底常量);并**不计入**当次普通 `reward_win` 那条配置(与「中豹子不走普通奖」逻辑一致,详见代码注释)。
杀分路径下:**不触发**豹子奖,仅展示非豹子组合。
### 4.5 T5「再来一次」
若命中奖励属于 **T5** 档位(且未走「仅豹子清掉普通奖」的特殊分支):在事务内为玩家 **`free_ticket_count + 1`**,并写入券流水备注;同时写入 Redis**下一局免费抽奖必须使用本局相同 `ante`**。
### 4.6 彩金池盈利累计
**`default`** 那条池子上更新 **`profit_amount`**
- **付费局**:本局贡献 `+= (本局总中奖 win_coin) - (本局付费 paid_amount)`,其中 `paid_amount = ante × 100`
- **免费局**`+= win_coin`(无票价成本,`paid_amount = 0`)。
该累计值与 **`safety_line``kill_enabled`** 共同决定下一局付费是否进入 **killScore** 档位权重(见 4.2)。
> 注意:仓库中部分数据库迁移脚本对 `profit_amount` 的注释可能仍沿用旧口径(例如按 `100-real_ev` 解释)。当前线上行为应以 `PlayStartLogic` 中对 `profit_amount` 的实际累加逻辑为准。
---
## 5. 数据与配置要点(实现侧)
- **`DiceReward`**:按档位、方向组织好的多语言/展示与 `grid_number``weight``real_ev` 等,供开局加权抽取。
- **`DiceRewardConfig`**:含 **BIGWIN** 档及普通档;`getCachedMinRealEv()` 等用于全局限定。
- **`dice_lottery_pool_config`**`default` / `killScore` 的 T1T5 权重及杀分相关开关、安全线、累计盈利。
- **对局表 `DicePlayRecord`**:记录 `lottery_config_id``lottery_type`(付费/免费)、`ante``paid_amount``roll_array``reward_config_id`、各类中奖拆分字段等,供后台与平台对账。
---
## 6. 接口与文档索引
| 文档 | 内容 |
| --- | --- |
| [`API对接文档.md`](API对接文档.md) | 平台 `/api/v1/*``auth-token`)、玩家 `/api/*``token`)、统一返回码、联调建议。 |
| `server/docs/` | 性能、权重测试、出点分析等专项说明(按需阅读)。 |
**与玩法直接相关的玩家接口示例**
- `GET /api/game/config` — 前端文案与分组配置
- `GET /api/game/anteConfig` — 可选注数
- `GET /api/game/lotteryPool` — 彩金池展示列表(不含 BIGWIN 档)
- `POST /api/game/buyLotteryTickets` — 购买套餐(更新次数统计)
- `POST /api/game/playStart` — 开局一局(`direction``ante`
---
## 7. 修订说明
- 本文档依据 `server/app/api/logic/PlayStartLogic.php``GameLogic.php``LotteryService.php``GameController` 当前实现整理;若业务规则变更,请以代码与数据库迁移为准并同步更新本节与 [`API对接文档.md`](API对接文档.md)。