first commit
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
.vscode
|
||||
|
||||
# Build output
|
||||
target/
|
||||
build/
|
||||
*.jar
|
||||
!ruoyi-admin/target/ruoyi-admin.jar
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
nohup.out
|
||||
logs/
|
||||
|
||||
# Docker
|
||||
docker/compose/.env
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Misc
|
||||
*.swp
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.claude/
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
reviews
|
||||
######################################################################
|
||||
# Build Tools
|
||||
|
||||
.gradle
|
||||
/build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
|
||||
######################################################################
|
||||
# IDE
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### JRebel ###
|
||||
rebel.xml
|
||||
|
||||
### NetBeans ###
|
||||
nbproject/private/
|
||||
build/*
|
||||
nbbuild/
|
||||
dist/
|
||||
nbdist/
|
||||
.nb-gradle/
|
||||
|
||||
######################################################################
|
||||
# Others
|
||||
*.log
|
||||
*.xml.versionsBackup
|
||||
*.swp
|
||||
|
||||
!*/build/*.java
|
||||
!*/build/*.html
|
||||
!*/build/*.xml
|
||||
|
||||
**/.vscode/
|
||||
**/__pycache__/
|
||||
**/uv.lock
|
||||
|
||||
.claude/
|
||||
|
||||
######################################################################
|
||||
# Docker
|
||||
docker/backup/
|
||||
@@ -0,0 +1,113 @@
|
||||
// 只读证据收集:后台写接口 → 前端按钮文案(含出处),供人工核对后写进 set-known-audit-logs.js
|
||||
//
|
||||
// node bin/checks/evidence.js 输出到 stdout(很长,建议重定向)
|
||||
// node bin/checks/evidence.js > ev.txt
|
||||
//
|
||||
// 链路:controller 端点 → api 函数(按 url+method 匹配)→ 调用该函数的 handler → 绑定该 handler 的按钮文案
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const UI = path.join(ROOT, 'ruoyi-ui', 'src');
|
||||
|
||||
function walk(dir, out, filter) {
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'dist') walk(p, out, filter); }
|
||||
else if (!filter || filter(e.name)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const norm = (u) => String(u).replace(/\$\{[^}]*\}/g, '{}').replace(/\{[^}]*\}/g, '{}')
|
||||
.replace(/\/+/g, '/').replace(/\/$/, '').replace(/['"`;\s]/, '');
|
||||
|
||||
// ---------- 1. api 函数 → url/method ----------
|
||||
const apiEntries = []; // {fn, url, method, module}
|
||||
for (const f of walk(path.join(UI, 'api'), [], (n) => n.endsWith('.js'))) {
|
||||
const src = fs.readFileSync(f, 'utf8');
|
||||
const mod = '@/api/' + path.relative(path.join(UI, 'api'), f).replace(/\\/g, '/').replace(/\.js$/, '');
|
||||
for (const m of src.matchAll(/export function (\w+)\s*\(([\s\S]{0,600}?)\n\}/g)) {
|
||||
const body = m[2];
|
||||
const url = (body.match(/url:\s*([`'"])([^`'"]+)\1/) || [])[2];
|
||||
const method = ((body.match(/method:\s*['"](\w+)['"]/) || [])[1] || 'get').toUpperCase();
|
||||
if (url) apiEntries.push({ fn: m[1], url, method, module: mod, file: path.relative(ROOT, f) });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 2. .vue 里的按钮 + handler → api 调用 ----------
|
||||
const vueFiles = walk(path.join(UI, 'views'), [], (n) => n.endsWith('.vue'));
|
||||
const fnEvidence = new Map(); // fn -> [{vue,line,label}]
|
||||
for (const f of vueFiles) {
|
||||
const src = fs.readFileSync(f, 'utf8');
|
||||
if (!/from ['"]@\/api\//.test(src)) continue;
|
||||
const rel = path.relative(ROOT, f);
|
||||
// 按钮:<el-button ... @click="h" ...>文字</el-button> / el-dropdown-item
|
||||
for (const m of src.matchAll(/<el-(?:button|dropdown-item)[^>]*?@click(?:\.native)?="([\w.]+)[^"]*"[\s\S]{0,400}?>\s*([^<>{}]{1,20}?)\s*<\/el-(?:button|dropdown-item)>/g)) {
|
||||
const handler = m[1].split('.')[0];
|
||||
const label = m[2].trim();
|
||||
const line = (src.slice(0, m.index).match(/\n/g) || []).length + 1;
|
||||
// handler 函数体:从定义处到下一个顶层方法定义(截断保护),比按第一个 } 截更可靠
|
||||
const hm = src.match(new RegExp('(?:^|\\n)\\s*' + handler + '\\s*\\([^)]*\\)\\s*\\{'));
|
||||
if (!hm) continue;
|
||||
const rest = src.slice(hm.index + hm[0].length);
|
||||
const nextDef = rest.search(/\n\s{0,4}[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/);
|
||||
const body = rest.slice(0, nextDef < 0 ? 3000 : Math.min(nextDef, 3000));
|
||||
for (const e of apiEntries) {
|
||||
if (new RegExp('\\b' + e.fn + '\\s*\\(').test(body)) {
|
||||
if (!fnEvidence.has(e.fn)) fnEvidence.set(e.fn, []);
|
||||
fnEvidence.get(e.fn).push({ vue: rel, line, label });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 3. controller 端点 ----------
|
||||
const isBlockMember = (l) => /^\s*(\/\/|\*|\/\*|@)/.test(l) || (/^\s*[\w.]+\s*=/.test(l) && /[,)]\s*$/.test(l));
|
||||
const endpoints = [];
|
||||
for (const f of walk(path.join(ROOT, 'skins-service'), [], (n) => n.endsWith('Controller.java'))
|
||||
.concat(walk(path.join(ROOT, 'skins-promo'), [], (n) => n.endsWith('Controller.java')))) {
|
||||
const src = fs.readFileSync(f, 'utf8');
|
||||
const base = (src.match(/@RequestMapping\(\s*(?:value\s*=\s*)?"([^"]*)"/) || [, null])[1];
|
||||
if (!base || !base.startsWith('/admin')) continue;
|
||||
const lines = src.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const mm = lines[i].match(/^\s*@(Get|Post|Put|Delete)Mapping\b(.*)$/);
|
||||
if (!mm) continue;
|
||||
if (mm[1].toUpperCase() === 'GET' && !/reset|clear|delete|remove|update|save|apply|generate|sync|send|publish|toggle|adjust|grant|cancel|recalc/i.test(lines[i])) {
|
||||
// GET 默认跳过,除非名字像改数据
|
||||
}
|
||||
let top = i; for (let j = i - 1; j >= 0; j--) { if (lines[j].trim() === '' || !isBlockMember(lines[j])) break; top = j; }
|
||||
const block = lines.slice(top, i).join('\n');
|
||||
let name = null;
|
||||
for (let j = i + 1; j < Math.min(i + 14, lines.length); j++) {
|
||||
const s = lines[j].match(/^\s*(?:public|protected|private)\s+[\w<>\[\],\.\? ]+\s+(\w+)\s*\(/); if (s) { name = s[1]; break; } }
|
||||
const sub = (mm[2].match(/"([^"]*)"/) || [, ''])[1];
|
||||
endpoints.push({
|
||||
controller: path.basename(f, '.java'), method: name, http: mm[1].toUpperCase(),
|
||||
url: base + sub, human: /menu\s*=/.test(block) || /action\s*=/.test(block), hasLog: /@Log\(/.test(block),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 4. 关联 ----------
|
||||
const out = [];
|
||||
for (const ep of endpoints) {
|
||||
if (ep.http === 'GET' && !/reset|clear|delete|remove|update|save|apply|generate|sync|send|publish|toggle|adjust|grant|cancel|recalc/i.test(ep.method || '')) continue;
|
||||
if (ep.http === 'GET' && !/reset|clear|delete|remove|update|save|apply|generate|sync|send|publish|toggle|adjust|grant|cancel|recalc/i.test(ep.url)) continue;
|
||||
const hit = apiEntries.find((e) => e.method === ep.http && norm(e.url) === norm(ep.url))
|
||||
|| apiEntries.find((e) => e.method === ep.http && norm(e.url).startsWith(norm(ep.url) + '/'));
|
||||
const ev = hit ? (fnEvidence.get(hit.fn) || []) : [];
|
||||
out.push({ ep, hit, ev });
|
||||
}
|
||||
|
||||
const state = (ep) => (!ep.hasLog ? '不记' : ep.human ? '已人话' : '旧JSON');
|
||||
const onlyTodo = process.argv.includes('--todo');
|
||||
console.log('controller.method | HTTP url | 现状 | 建议文案 | 证据');
|
||||
for (const { ep, hit, ev } of out) {
|
||||
if (onlyTodo && ep.human) continue;
|
||||
const labels = [...new Set(ev.map((x) => x.label))];
|
||||
const suggest = labels.length === 1 ? labels[0] : labels.length === 0 ? '—(无按钮证据)' : labels.map((l) => l + '?').join('/');
|
||||
const evidence = ev.slice(0, 2).map((x) => `${x.vue}:${x.line}`).join(' ') || (hit ? `api ${hit.fn} 无按钮绑定` : '无 api');
|
||||
console.log(`${(ep.controller + '.' + ep.method).padEnd(52)} ${ep.http.padEnd(6)} ${ep.url.padEnd(44)} ${state(ep).padEnd(6)} ${suggest.padEnd(22)} ${evidence}`);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
;(async () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../ruoyi-ui/src/utils/operationAudit.js'), 'utf8')
|
||||
const { parseOperationAudit: parse } = await import('data:text/javascript;base64,' + Buffer.from(source).toString('base64'))
|
||||
const payload = { auditVersion: 1, action: '修改', target: { id: 18, name: '测试用户' }, state: 'changed', changes: [
|
||||
{ label: '账户余额', before: '100', after: '200' }, { label: '账户积分', before: '50', after: '80' }
|
||||
], truncated: false }
|
||||
const result = parse(JSON.stringify(payload))
|
||||
assert.equal(result.changes.length, 2)
|
||||
assert.equal(result.changes[0].before, '100')
|
||||
assert.match(result.target, /18/)
|
||||
for (const state of ['failed', 'unavailable', 'unchanged']) {
|
||||
const p = parse(JSON.stringify({ ...payload, state }))
|
||||
assert.equal(p.changes.length, 0)
|
||||
assert.ok(p.notice)
|
||||
}
|
||||
assert.match(parse(JSON.stringify({ ...payload, truncated: true })).notice, /上限/)
|
||||
assert.equal(parse('点击【修改】|余额\t100\t200').changes.length, 1)
|
||||
assert.equal(parse('点击【修改】').changes.length, 0)
|
||||
assert.equal(parse('{"password":"secret"}').action, '历史记录')
|
||||
assert.equal(parse('{malformed').action, '历史记录')
|
||||
const dangerous = '汉字|\t\n<img onerror=alert(1)>;→'
|
||||
assert.equal(parse(JSON.stringify({ ...payload, changes: [{ label: '昵称', before: '', after: dangerous }] })).changes[0].after, dangerous)
|
||||
const vue = fs.readFileSync(path.join(__dirname, '../../ruoyi-ui/src/views/monitor/operlog/index.vue'), 'utf8')
|
||||
assert.ok(!vue.includes('v-html'))
|
||||
const compiler = require('../../ruoyi-ui/node_modules/vue-template-compiler')
|
||||
const template = compiler.parseComponent(vue).template.content
|
||||
assert.deepEqual(compiler.compile(template).errors, [])
|
||||
console.log('operation-audit parser and Vue template PASSED')
|
||||
})().catch(e => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,17 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Resolve-Path (Join-Path $PSScriptRoot '../..')
|
||||
Push-Location $root
|
||||
try {
|
||||
& mvn -o -pl ruoyi-admin -am test-compile -DskipTests -q
|
||||
if ($LASTEXITCODE -ne 0) { throw 'test-compile failed' }
|
||||
$cpFile = Join-Path $root 'ruoyi-admin/target/audit-classpath.txt'
|
||||
& mvn -o -q -pl ruoyi-admin dependency:build-classpath "-Dmdep.outputFile=$cpFile"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'classpath generation failed' }
|
||||
$classes = @('ruoyi-admin/target/test-classes','ruoyi-admin/target/classes','ruoyi-common/target/classes','ruoyi-framework/target/classes','ruoyi-system/target/classes','skins-model/target/classes','skins-service/service-admin/target/classes')
|
||||
$cp = (($classes -join ';') + ';' + (Get-Content $cpFile -Raw).Trim()).Replace('\','/')
|
||||
$argFile = Join-Path $root 'ruoyi-admin/target/audit-java.args'
|
||||
$arguments = @('-Dfile.encoding=UTF-8', '-cp', ('"' + $cp + '"'), 'com.ruoyi.audit.UserOperationAuditTest')
|
||||
[System.IO.File]::WriteAllLines($argFile, $arguments, (New-Object System.Text.UTF8Encoding($false)))
|
||||
& java "@$argFile"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'audit regression failed' }
|
||||
} finally { Pop-Location }
|
||||
@@ -0,0 +1,405 @@
|
||||
// 按「方法名 → 注解」显式白名单写入 @Log(不做 URL 猜配,避免错位)
|
||||
//
|
||||
// node bin/checks/set-known-audit-logs.js 干跑
|
||||
// node bin/checks/set-known-audit-logs.js --apply 落盘
|
||||
//
|
||||
// 按钮文案全部取自页面源码原文(每条的来源写在本表注释里,格式:views/xxx.vue:行号):
|
||||
// 工具条按钮(生成招商账号/一键上架/发放CDK)优先于弹窗里的通用【确 定】——
|
||||
// 运营的心智是"我点了那颗有名字的按钮",【确 定】记进日志等于没信息。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const apply = process.argv.includes('--apply');
|
||||
const A = 'skins-service/service-admin/src/main/java/com/ruoyi/admin/controller/';
|
||||
const W = 'skins-service/game-wheel-service/src/main/java/com/ruoyi/game/wheel/controller/';
|
||||
const O = 'skins-service/game-olympus-service/src/main/java/com/ruoyi/game/olympus/controller/';
|
||||
|
||||
const TARGETS = [
|
||||
// ---- 用户列表(ttuser/index.vue:工具条 113/121/129,行内 86/96,下拉 212/218/224)----
|
||||
[A + 'TtUserController.java', 'generateAccount', { menu: '用户列表', action: '生成招商账号', type: 'INSERT' }],
|
||||
[A + 'TtUserController.java', 'generateRobot', { menu: '用户列表', action: '生成机器人', type: 'INSERT' }],
|
||||
[A + 'TtUserController.java', 'generateBlankAccount', { menu: '用户列表', action: '生成白号', type: 'INSERT' }],
|
||||
[A + 'TtUserController.java', 'generateCardMerchant', { menu: '用户列表', action: '生成卡商账号', type: 'INSERT' }],
|
||||
[A + 'TtUserController.java', 'edit', { menu: '用户列表', action: '修改', type: 'UPDATE', entity: 'TtUser', mapper: 'TtUserMapper' }],
|
||||
[A + 'TtUserController.java', 'editInvitationCode', { menu: '用户列表', action: '修改邀请码', type: 'UPDATE', entity: 'TtUser', mapper: 'TtUserMapper' }],
|
||||
[A + 'TtUserController.java', 'remove', { menu: '用户列表', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtUserController.java', 'removeUserPackSackData', { menu: '背包列表', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtUserController.java', 'resetUserBalance', { menu: '用户列表', action: '重置余额积分', type: 'CLEAN', entity: 'TtUser', mapper: 'TtUserMapper', idField: 'userId' }],
|
||||
[A + 'TtUserController.java', 'forceDelete', { menu: '用户列表', action: '真删除', type: 'FORCE' }],
|
||||
|
||||
// ---- 亏损返水(ttredPacket/lossRebate.vue:31 保存上限 / 112 发放CDK)----
|
||||
[A + 'LossRebateController.java', 'grant', { menu: '亏损返水', action: '发放CDK', type: 'GRANT', entity: 'TtLossRebate', mapper: 'TtLossRebateMapper', idField: 'id' }],
|
||||
[A + 'LossRebateController.java', 'saveConfig', { menu: '亏损返水', action: '保存上限', type: 'UPDATE' }],
|
||||
|
||||
// ---- 卡商充值(ttCardMerchantRecharge/index.vue:126 确认充值 / 160 确认修改)----
|
||||
[A + 'TtCardMerchantController.java', 'cardMerchantRecharge', { menu: '卡商充值', action: '确认充值', type: 'GRANT', entity: 'TtUser', mapper: 'TtUserMapper', idField: 'userId' }],
|
||||
[A + 'TtCardMerchantController.java', 'cardMerchantUpdateBalance', { menu: '卡商充值', action: '确认修改', type: 'UPDATE', entity: 'TtUser', mapper: 'TtUserMapper', idField: 'userId' }],
|
||||
|
||||
// ---- 生成空白卡密(blankCardKey/index.vue:5 生成空白卡密 开弹窗)----
|
||||
[A + 'AdminBlankCardKeyController.java', 'generate', { menu: '生成空白卡密', action: '生成空白卡密', type: 'INSERT' }],
|
||||
|
||||
// ---- 疯狂转盘 游戏配置(game/wheel/config/index.vue:8 重置奖池 / 44 调整奖池 / 241 保存)----
|
||||
[W + 'AdminWheelController.java', 'resetPool', { menu: '游戏配置', action: '重置奖池', type: 'CLEAN' }],
|
||||
[W + 'AdminWheelController.java', 'adjustPool', { menu: '游戏配置', action: '调整奖池', type: 'GRANT' }],
|
||||
[W + 'AdminWheelController.java', 'updateGameConfig', { menu: '游戏配置', action: '保存', type: 'UPDATE' }],
|
||||
// ---- 疯狂转盘 奖励矩阵(game/wheel/prize/index.vue:79 保存 / 89 全部保存 → saveMatrix)----
|
||||
[W + 'AdminWheelController.java', 'matrixSave', { menu: '奖励矩阵', action: '保存', type: 'UPDATE' }],
|
||||
|
||||
// ---- 商城列表(ttShopping/index.vue:75 一键下架 / 82 一键上架)----
|
||||
[A + 'ShoppingController.java', 'putAwayAll', { menu: '商城列表', action: '一键上架', type: 'UPDATE' }],
|
||||
[A + 'ShoppingController.java', 'soldOutAll', { menu: '商城列表', action: '一键下架', type: 'UPDATE' }],
|
||||
|
||||
// ---- Roll房列表(ttRoll/index.vue:222 新 增 / 223 修 改 / 120 删除)----
|
||||
[A + 'TtRollController.java', 'add', { menu: 'Roll房列表', action: '新增', type: 'INSERT', entity: 'TtRoll', mapper: 'TtRollMapper' }],
|
||||
[A + 'TtRollController.java', 'edit', { menu: 'Roll房列表', action: '修改', type: 'UPDATE', entity: 'TtRoll', mapper: 'TtRollMapper' }],
|
||||
[A + 'TtRollController.java', 'remove', { menu: 'Roll房列表', action: '删除', type: 'DELETE' }],
|
||||
|
||||
// ---- 抽奖活动(turnoverLottery/list.vue:43 新增抽奖活动 / 86 修改 / 92 删除)----
|
||||
[A + 'TurnoverLotteryAdminController.java', 'create', { menu: '抽奖活动', action: '新增抽奖活动', type: 'INSERT' }],
|
||||
[A + 'TurnoverLotteryAdminController.java', 'update', { menu: '抽奖活动', action: '修改', type: 'UPDATE' }],
|
||||
[A + 'TurnoverLotteryAdminController.java', 'delete', { menu: '抽奖活动', action: '删除', type: 'DELETE' }],
|
||||
|
||||
// ---- 奥林匹斯 奖池水位(game/olympus/rewardPool/index.vue:101/106 批量注水|抽水、111 重置调整、172/175 行内图标→弹窗 213 确定注水|抽水)----
|
||||
[O + 'OlympusAdminController.java', 'rewardPoolAdjust', { menu: '奖池水位', action: '注水/抽水', type: 'GRANT' }],
|
||||
[O + 'OlympusAdminController.java', 'rewardPoolAdjustBatch', { menu: '奖池水位', action: '批量注水/抽水', type: 'GRANT' }],
|
||||
[O + 'OlympusAdminController.java', 'rewardPoolAdjustReset', { menu: '奖池水位', action: '重置调整', type: 'CLEAN' }],
|
||||
|
||||
// ---- 宝箱列表(ttBox/index.vue:48 新增 / 59 修改 / 70 删除 / 282 重置宝箱)----
|
||||
[A + 'TtBoxController.java', 'add', { menu: '宝箱列表', action: '新增', type: 'INSERT', entity: 'TtBox', mapper: 'TtBoxMapper' }],
|
||||
[A + 'TtBoxController.java', 'edit', { menu: '宝箱列表', action: '修改', type: 'UPDATE' }],
|
||||
[A + 'TtBoxController.java', 'remove', { menu: '宝箱列表', action: '删除', type: 'DELETE' }],
|
||||
// 注意:resetBox 是 @GetMapping,但它改数据(GET 不在常规扫描范围内,这里显式补上)
|
||||
[A + 'TtBoxController.java', 'resetBox', { menu: '宝箱列表', action: '重置宝箱', type: 'CLEAN', entity: 'TtBox', mapper: 'TtBoxMapper', idField: 'boxId' }],
|
||||
|
||||
// ---- Roll 奖品/房间(awarddetali.vue:22 指定获奖者 / 23 取消奖品;rollUser.vue:10 加入机器人 / 18 加入用户)----
|
||||
[A + 'TtRollController.java', 'namedWinner', { menu: '装备列表', action: '指定获奖者', type: 'INSERT' }],
|
||||
[A + 'TtRollController.java', 'cancelNamedWinner', { menu: '装备列表', action: '取消奖品', type: 'DELETE' }],
|
||||
[A + 'TtRollController.java', 'addRobot', { menu: '房间用户', action: '加入机器人', type: 'INSERT' }],
|
||||
[A + 'TtRollController.java', 'addUser', { menu: '房间用户', action: '加入用户', type: 'INSERT' }],
|
||||
|
||||
// ---- 奥林匹斯 箱子配置(game/olympus/box/index.vue:53 编辑 / 180 保存 / 15+141 一键重算分布 / 179 保存为模板 / 214 应用选中模板 / 207 删除模板)----
|
||||
[O + 'OlympusAdminController.java', 'boxSave', { menu: '箱子配置', action: '编辑', type: 'UPDATE' }],
|
||||
[O + 'OlympusAdminController.java', 'boxOrnamentsSave', { menu: '箱子配置', action: '保存', type: 'UPDATE' }],
|
||||
[O + 'OlympusAdminController.java', 'recalcBatch', { menu: '箱子配置', action: '一键重算分布', type: 'CLEAN' }],
|
||||
[O + 'OlympusAdminController.java', 'templateSave', { menu: '箱子配置', action: '保存为模板', type: 'INSERT' }],
|
||||
[O + 'OlympusAdminController.java', 'templateApply', { menu: '箱子配置', action: '应用选中模板', type: 'UPDATE' }],
|
||||
[O + 'OlympusAdminController.java', 'templateDelete', { menu: '箱子配置', action: '删除', type: 'DELETE' }],
|
||||
// ---- 奥林匹斯 奖品管理(game/olympus/prize/index.vue:44 编辑 → 87 确定 → prizeSave)----
|
||||
[O + 'OlympusAdminController.java', 'prizeSave', { menu: '奖品管理', action: '编辑', type: 'UPDATE' }],
|
||||
|
||||
// ---- 消费排行榜奖励(rechargeRankingReward.vue:5 生成消费奖励 / 23 修改 / 8 重置→handleReset:165)----
|
||||
[A + 'TtRechargeRankingRewardController.java', 'generatePromotionLevel', { menu: '消费排行榜奖励', action: '生成消费奖励', type: 'UPDATE' }],
|
||||
[A + 'TtRechargeRankingRewardController.java', 'edit', { menu: '消费排行榜奖励', action: '修改', type: 'UPDATE' }],
|
||||
[A + 'TtRechargeRankingRewardController.java', 'truncateRankingReward', { menu: '消费排行榜奖励', action: '重置', type: 'CLEAN' }],
|
||||
|
||||
// ---- 对战排行榜奖励(fightRankingReward.vue:5 生成排名奖励 / 23 修改 / 8 重置→handleReset:167)----
|
||||
[A + 'TtFightRankingRewardController.java', 'generatePromotionLevel', { menu: '对战排行榜奖励', action: '生成排名奖励', type: 'UPDATE' }],
|
||||
[A + 'TtFightRankingRewardController.java', 'edit', { menu: '对战排行榜奖励', action: '修改', type: 'UPDATE' }],
|
||||
[A + 'TtFightRankingRewardController.java', 'truncateRankingReward', { menu: '对战排行榜奖励', action: '重置', type: 'CLEAN' }],
|
||||
|
||||
// ---- 饰品列表(ttOrnaments/index.vue:58 新增饰品 / 123 编辑 / 125=77 删除 / 117 发放饰品)----
|
||||
[A + 'TtOrnamentsController.java', 'add', { menu: '饰品列表', action: '新增饰品', type: 'INSERT', entity: 'TtOrnament', mapper: 'TtOrnamentMapper' }],
|
||||
[A + 'TtOrnamentsController.java', 'edit', { menu: '饰品列表', action: '编辑', type: 'UPDATE', entity: 'TtOrnament', mapper: 'TtOrnamentMapper' }],
|
||||
[A + 'TtOrnamentsController.java', 'delete', { menu: '饰品列表', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtOrnamentsController.java', 'grantOrnaments', { menu: '饰品列表', action: '发放饰品', type: 'GRANT' }],
|
||||
|
||||
// ================= 以下为"存量旧 JSON 格式"转人话(按钮文案已逐页核实:都是标准 新增/修改/删除)=================
|
||||
// ---- 福利列表 / 福利开箱记录(ttWelfare/index.vue、welfareBoxRecords.vue)----
|
||||
[A + 'TtWelfareController.java', 'add', { menu: '福利列表', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtWelfareController.java', 'edit', { menu: '福利列表', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtWelfareController.java', 'remove', { menu: '福利列表', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtWelfareController.java', 'removeBoxRecords', { menu: '福利开箱记录', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtWelfareController.java', 'clearBoxRecords', { menu: '福利开箱记录', action: '清空全部记录', type: 'CLEAN' }],
|
||||
|
||||
// ---- 每月充值福利(monthlyRecharges.vue)----
|
||||
[A + 'TtWelfareMonthlyRechargesController.java', 'add', { menu: '每月充值福利', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtWelfareMonthlyRechargesController.java', 'edit', { menu: '每月充值福利', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtWelfareMonthlyRechargesController.java', 'remove', { menu: '每月充值福利', action: '删除', type: 'DELETE' }],
|
||||
|
||||
// ---- 首充赠送(ttBonus/firstRecharge.vue)----
|
||||
[A + 'TtFirstRechargeController.java', 'add', { menu: '首充赠送', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtFirstRechargeController.java', 'edit', { menu: '首充赠送', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtFirstRechargeController.java', 'remove', { menu: '首充赠送', action: '删除', type: 'DELETE' }],
|
||||
|
||||
// ---- CDK管理(ttRedPack/index.vue)----
|
||||
[A + 'TtRedPackController.java', 'add', { menu: 'CDK管理', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtRedPackController.java', 'edit', { menu: 'CDK管理', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtRedPackController.java', 'remove', { menu: 'CDK管理', action: '删除', type: 'DELETE' }],
|
||||
|
||||
// ---- 汰换记录(ttReplacementRecord/index.vue)----
|
||||
[A + 'TtReplacementRecordController.java', 'add', { menu: '汰换记录', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtReplacementRecordController.java', 'edit', { menu: '汰换记录', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtReplacementRecordController.java', 'remove', { menu: '汰换记录', action: '删除', type: 'DELETE' }],
|
||||
|
||||
// ---- 机器人对战分组(ttFight/robotFightGroup.vue)----
|
||||
[A + 'TtRobotFightGroupController.java', 'add', { menu: '机器人对战分组', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtRobotFightGroupController.java', 'edit', { menu: '机器人对战分组', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtRobotFightGroupController.java', 'remove', { menu: '机器人对战分组', action: '删除', type: 'DELETE' }],
|
||||
|
||||
// ---- 机器人对战分组宝箱(ttFight/robotFightGroupBox.vue)----
|
||||
[A + 'TtRobotFightGroupBoxController.java', 'add', { menu: '机器人对战分组宝箱', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtRobotFightGroupBoxController.java', 'edit', { menu: '机器人对战分组宝箱', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtRobotFightGroupBoxController.java', 'remove', { menu: '机器人对战分组宝箱', action: '删除', type: 'DELETE' }],
|
||||
|
||||
// ---- 机器人对战配置(ttFight/robotFightConfig.vue,两个 controller 共用一页)----
|
||||
[A + 'TtRobotFightJoinConfigController.java', 'add', { menu: '机器人对战配置', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtRobotFightJoinConfigController.java', 'edit', { menu: '机器人对战配置', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtRobotFightJoinConfigController.java', 'remove', { menu: '机器人对战配置', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtRobotFightPriceConfigController.java', 'add', { menu: '机器人对战配置', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtRobotFightPriceConfigController.java', 'edit', { menu: '机器人对战配置', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtRobotFightPriceConfigController.java', 'remove', { menu: '机器人对战配置', action: '删除', type: 'DELETE' }],
|
||||
|
||||
// ---- 转赠申请(delivery/transferApply.vue:同意 / 驳回 / 确定同意)----
|
||||
[A + 'TtTransferApplyController.java', 'approve', { menu: '转赠申请', action: '同意', type: 'GRANT' }],
|
||||
[A + 'TtTransferApplyController.java', 'reject', { menu: '转赠申请', action: '驳回', type: 'GRANT' }],
|
||||
[A + 'TtTransferApplyController.java', 'approveBatch', { menu: '转赠申请', action: '确定同意', type: 'GRANT' }],
|
||||
[A + 'TtTransferApplyController.java', 'rejectBatch', { menu: '转赠申请', action: '驳回', type: 'GRANT' }],
|
||||
|
||||
// ---- 任务列表(ttTaskCenter/index.vue)----
|
||||
[A + 'TtTaskCenterController.java', 'add', { menu: '任务列表', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtTaskCenterController.java', 'edit', { menu: '任务列表', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtTaskCenterController.java', 'remove', { menu: '任务列表', action: '删除', type: 'DELETE' }],
|
||||
|
||||
// ---- 开箱机会(ttOrnaments/boxOpenChance.vue;库里无同名菜单,用页面功能名)----
|
||||
[A + 'TtBoxOpenChanceController.java', 'add', { menu: '开箱机会', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtBoxOpenChanceController.java', 'edit', { menu: '开箱机会', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtBoxOpenChanceController.java', 'remove', { menu: '开箱机会', action: '删除', type: 'DELETE' }],
|
||||
|
||||
// ================= 其余有按钮证据的页面(标签来自 evidence.js:直接调用方的按钮文案,CRUD 页按工具条 新增/修改/删除)=================
|
||||
// ---- 公告(ttSetting/announcement.vue)----
|
||||
[A + 'TtAnnouncementController.java', 'addAnnouncement', { menu: '公告', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtAnnouncementController.java', 'editAnnouncement', { menu: '公告', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtAnnouncementController.java', 'removeAnnouncementByAnnouncementId', { menu: '公告', action: '删除', type: 'DELETE' }],
|
||||
// ---- Banner设置 / 彩金 / 文章 / 文章类型 / 宝箱分类(ttSetting/banner.vue, ttBonus/index.vue, contentList, contentType, ttBox/boxType)----
|
||||
[A + 'TtBannerController.java', 'add', { menu: 'Banner设置', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtBannerController.java', 'edit', { menu: 'Banner设置', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtBannerController.java', 'remove', { menu: 'Banner设置', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtBonusController.java', 'add', { menu: '彩金', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtBonusController.java', 'edit', { menu: '彩金', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtBonusController.java', 'remove', { menu: '彩金', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtContentController.java', 'add', { menu: '文章', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtContentController.java', 'edit', { menu: '文章', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtContentController.java', 'remove', { menu: '文章', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtContentTypeController.java', 'add', { menu: '文章类型', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtContentTypeController.java', 'edit', { menu: '文章类型', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtContentTypeController.java', 'remove', { menu: '文章类型', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtBoxTypeController.java', 'add', { menu: '宝箱分类', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtBoxTypeController.java', 'edit', { menu: '宝箱分类', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtBoxTypeController.java', 'remove', { menu: '宝箱分类', action: '删除', type: 'DELETE' }],
|
||||
// ---- 宝箱饰品(挂在宝箱列表下;批量填货按钮见 ttBox/index.vue:283)----
|
||||
[A + 'TtBoxOrnamentsController.java', 'add', { menu: '宝箱列表', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtBoxOrnamentsController.java', 'edit', { menu: '宝箱列表', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtBoxOrnamentsController.java', 'remove', { menu: '宝箱列表', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtBoxOrnamentsController.java', 'batchAdd', { menu: '宝箱列表', action: '批量填货', type: 'INSERT' }],
|
||||
// ---- 等级类(ttOrnamentsLevel/index.vue, ttSetting/promotionLevel.vue, ttSetting/vips.vue)----
|
||||
[A + 'TtOrnamentsLevelController.java', 'edit', { menu: '饰品等级', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtOrnamentsLevelController.java', 'generateOrnamentsLevel', { menu: '饰品等级', action: '新增', type: 'INSERT' }],
|
||||
[A + 'TtPromotionLevelController.java', 'edit', { menu: '推广等级设置', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtPromotionLevelController.java', 'generatePromotionLevel', { menu: '推广等级设置', action: '生成推广列表', type: 'INSERT' }],
|
||||
[A + 'TtPromotionLevelController.java', 'truncateVipLevel', { menu: '推广等级设置', action: '重置', type: 'CLEAN' }],
|
||||
[A + 'TtVipLevelController.java', 'edit', { menu: 'VIP等级设置', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtVipLevelController.java', 'generateVipLevel', { menu: 'VIP等级设置', action: '生成VIP等级', type: 'INSERT' }],
|
||||
[A + 'TtVipLevelController.java', 'truncateVipLevel', { menu: 'VIP等级设置', action: '重置', type: 'CLEAN' }],
|
||||
// ---- 充值/卡密(ttRechargeList/index.vue, ttRechargeRecord? , ttGenerateCard/index.vue:135)----
|
||||
[A + 'TtRechargeListController.java', 'add', { menu: '货币列表', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtRechargeListController.java', 'edit', { menu: '货币列表', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtRechargeListController.java', 'remove', { menu: '货币列表', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtRechargeConfigController.java', 'add', { menu: '网站货币管理', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtRechargeConfigController.java', 'edit', { menu: '网站货币管理', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtRechargeConfigController.java', 'remove', { menu: '网站货币管理', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtRechargeCardController.java', 'generateCard', { menu: '卡密', action: '生成卡密', type: 'INSERT' }],
|
||||
[A + 'TtRechargeCardController.java', 'generateBlankCard', { menu: '卡密', action: '生成空白卡密', type: 'INSERT' }],
|
||||
// ---- CDK口令 / Roll奖池 / 限时Roll / 官方周期Roll配置 / 奖项配置 ----
|
||||
[A + 'TtRedPacketController.java', 'add', { menu: 'CDK口令', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtRedPacketController.java', 'edit', { menu: 'CDK口令', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtRedPacketController.java', 'remove', { menu: 'CDK口令', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtRollJackpotController.java', 'add', { menu: '奖池列表', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtRollJackpotController.java', 'edit', { menu: '奖池列表', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtRollJackpotController.java', 'remove', { menu: '奖池列表', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtRollJackpotOrnamentsController.java', 'add', { menu: '奖池列表', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtRollJackpotOrnamentsController.java', 'edit', { menu: '奖池列表', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtRollJackpotOrnamentsController.java', 'remove', { menu: '奖池列表', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtTimeRollController.java', 'add', { menu: '时间roll房', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtTimeRollController.java', 'edit', { menu: '时间roll房', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtTimeRollController.java', 'remove', { menu: '时间roll房', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtOfficialRollConfigController.java', 'add', { menu: '官方周期Roll配置', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtOfficialRollConfigController.java', 'edit', { menu: '官方周期Roll配置', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtOfficialRollConfigController.java', 'remove', { menu: '官方周期Roll配置', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TurnoverLotteryPrizeController.java', 'add', { menu: '奖项配置', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TurnoverLotteryPrizeController.java', 'edit', { menu: '奖项配置', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TurnoverLotteryPrizeController.java', 'remove', { menu: '奖项配置', action: '删除', type: 'DELETE' }],
|
||||
// ---- 升级饰品 / 失败奖励(ttUpgrade/index.vue, upgradeFall.vue)----
|
||||
[A + 'TtUpgradeOrnamentsController.java', 'batchAdd', { menu: '饰品列表', action: '新增', type: 'INSERT' }],
|
||||
[A + 'TtUpgradeOrnamentsController.java', 'edit', { menu: '饰品列表', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtUpgradeOrnamentsController.java', 'remove', { menu: '饰品列表', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtUpgradeFailOrnamentsController.java', 'batchAdd', { menu: '失败奖励', action: '新增', type: 'INSERT' }],
|
||||
[A + 'TtUpgradeFailOrnamentsController.java', 'edit', { menu: '失败奖励', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtUpgradeFailOrnamentsController.java', 'remove', { menu: '失败奖励', action: '删除', type: 'DELETE' }],
|
||||
// ---- 头像设置 / 网站道具 / 发货记录 / 商城上下架 / 站点参数 ----
|
||||
[A + 'TtUserAvatarController.java', 'add', { menu: '头像设置', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'TtUserAvatarController.java', 'edit', { menu: '头像设置', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'TtUserAvatarController.java', 'remove', { menu: '头像设置', action: '删除', type: 'DELETE' }],
|
||||
[A + 'WebsitePropertyController.java', 'add', { menu: '网站道具', action: '新增', type: 'INSERT', auto: true }],
|
||||
[A + 'WebsitePropertyController.java', 'edit', { menu: '网站道具', action: '修改', type: 'UPDATE', auto: true }],
|
||||
[A + 'WebsitePropertyController.java', 'remove', { menu: '网站道具', action: '删除', type: 'DELETE' }],
|
||||
[A + 'TtDeliveryRecordController.java', 'deliveryApprove', { menu: '发货记录', action: '确认发货', type: 'GRANT' }],
|
||||
[A + 'ShoppingController.java', 'batchPutAwayOrSoldOut', { menu: '商城列表', action: '上架/下架', type: 'UPDATE' }],
|
||||
[A + 'WebsiteSetupController.java', 'updateParameterSetting', { menu: '网站设置', action: '保存', type: 'UPDATE' }],
|
||||
// ---- 疯狂转盘 奖励矩阵删除 ----
|
||||
[W + 'AdminWheelController.java', 'matrixDelete', { menu: '奖励矩阵', action: '删除', type: 'DELETE' }],
|
||||
];
|
||||
|
||||
const IMPORTS = {
|
||||
TtUser: 'com.ruoyi.domain.entity.sys.TtUser',
|
||||
TtUserMapper: 'com.ruoyi.admin.mapper.TtUserMapper',
|
||||
TtLossRebate: 'com.ruoyi.domain.entity.TtLossRebate',
|
||||
TtLossRebateMapper: 'com.ruoyi.admin.mapper.TtLossRebateMapper',
|
||||
TtRoll: 'com.ruoyi.domain.entity.roll.TtRoll',
|
||||
TtRollMapper: 'com.ruoyi.admin.mapper.TtRollMapper',
|
||||
TtBox: 'com.ruoyi.domain.other.TtBox',
|
||||
TtBoxMapper: 'com.ruoyi.admin.mapper.TtBoxMapper',
|
||||
TtOrnament: 'com.ruoyi.domain.entity.TtOrnament',
|
||||
TtOrnamentMapper: 'com.ruoyi.admin.mapper.TtOrnamentMapper',
|
||||
};
|
||||
|
||||
const isBlockMember = (l) =>
|
||||
/^\s*(\/\/|\*|\/\*|@)/.test(l) || (/^\s*[\w.]+\s*=/.test(l) && /[,)]\s*$/.test(l));
|
||||
|
||||
// ---- 自动解析实体/Mapper:attrs.auto = true 时,按方法入参里的实体类型去认 ----
|
||||
function collectFiles(dir, out, suffix) {
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) collectFiles(p, out, suffix);
|
||||
else if (e.isFile() && e.name.endsWith(suffix)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const ENTITY = new Map(); // simpleName -> fqn(skins-model 全部类;真正用的时候还要求有 Mapper)
|
||||
for (const f of collectFiles(path.join(ROOT, 'skins-model'), [], '.java')) {
|
||||
const src = fs.readFileSync(f, 'utf8');
|
||||
const cls = (src.match(/public\s+class\s+(\w+)/) || [, null])[1];
|
||||
const pkg = (src.match(/^package\s+([\w.]+);/m) || [, ''])[1];
|
||||
if (cls && !ENTITY.has(cls)) ENTITY.set(cls, `${pkg}.${cls}`);
|
||||
}
|
||||
const MAPPER = new Map(); // entitySimple -> {simple, fqn}(BaseMapper 与普通 MyBatis 接口都要认)
|
||||
for (const f of collectFiles(path.join(ROOT, 'skins-service'), [], 'Mapper.java')) {
|
||||
const simple = path.basename(f, '.java');
|
||||
const entityName = simple.endsWith('Mapper') ? simple.slice(0, -6) : null;
|
||||
if (!entityName || !ENTITY.has(entityName) || MAPPER.has(entityName)) continue;
|
||||
const pkg = (fs.readFileSync(f, 'utf8').match(/^package\s+([\w.]+);/m) || [, ''])[1];
|
||||
MAPPER.set(entityName, { simple, fqn: `${pkg}.${simple}` });
|
||||
}
|
||||
|
||||
/** 取方法参数里的类型名(优先 @RequestBody 那个) */
|
||||
function methodTypes(lines, sig) {
|
||||
let text = '';
|
||||
for (let i = sig; i < Math.min(sig + 10, lines.length); i++) {
|
||||
text += lines[i] + ' ';
|
||||
if (/\)\s*\{/.test(lines[i]) || /\)\s*$/.test(lines[i])) break;
|
||||
}
|
||||
const inner = (text.match(/\(([\s\S]*?)\)/) || [, ''])[1];
|
||||
const types = [];
|
||||
const rb = inner.match(/@RequestBody\s+(?:@\w+(?:\([^)]*\))?\s+)*(\w+)/);
|
||||
if (rb) types.push(rb[1]);
|
||||
for (const m of inner.matchAll(/(?:^|,)\s*(?:final\s+)?([A-Z]\w+)\s+\w+/g)) if (!types.includes(m[1])) types.push(m[1]);
|
||||
return types;
|
||||
}
|
||||
|
||||
function render(a, indent) {
|
||||
const parts = [`title = "${a.menu}"`, `action = "${a.action}"`, `businessType = BusinessType.${a.type}`];
|
||||
if (a.entity) parts.push(`entityClass = ${a.entity}.class`);
|
||||
if (a.mapper) parts.push(`mapperClass = ${a.mapper}.class`);
|
||||
if (a.idField) parts.push(`idField = "${a.idField}"`);
|
||||
const one = `${indent}@Log(${parts.join(', ')})`;
|
||||
if (one.length <= 130) return [one];
|
||||
return [`${indent}@Log(${parts.slice(0, 3).join(', ')},`, `${indent} ${parts.slice(3).join(', ')})`];
|
||||
}
|
||||
|
||||
function insertImports(lines, entries) {
|
||||
for (const e of entries) {
|
||||
// 集合里既有全限定名(com.ruoyi...),也有短名(TtUser),分开处理
|
||||
const fqn = e.includes('.') ? e : IMPORTS[e];
|
||||
if (!fqn) continue;
|
||||
const simple = fqn.split('.').pop();
|
||||
if (lines.some((l) => new RegExp(`^import\\s+[\\w.]*\\b${simple};`).test(l.trim()))) continue;
|
||||
let at = lines.findIndex((l) => /^import com\.ruoyi\./.test(l));
|
||||
if (at < 0) at = lines.findIndex((l) => /^import /.test(l));
|
||||
if (at >= 0) lines.splice(at, 0, `import ${fqn};`);
|
||||
}
|
||||
}
|
||||
|
||||
const byFile = new Map();
|
||||
for (const [file, method, attrs] of TARGETS) {
|
||||
if (!byFile.has(file)) byFile.set(file, []);
|
||||
byFile.get(file).push({ method, attrs });
|
||||
}
|
||||
if (process.argv.includes('--debug')) {
|
||||
console.log(`实体索引 ${ENTITY.size},Mapper 索引 ${MAPPER.size}`);
|
||||
for (const t of ['TtWelfare', 'TtRedPack', 'TtTaskCenter', 'TtBox']) {
|
||||
console.log(` ${t}: entity=${ENTITY.get(t) || '无'} mapper=${MAPPER.has(t) ? MAPPER.get(t).fqn : '无'}`);
|
||||
}
|
||||
}
|
||||
|
||||
let done = 0, skipped = 0;
|
||||
for (const [rel, list] of byFile) {
|
||||
const abs = path.join(ROOT, rel);
|
||||
const lines = fs.readFileSync(abs, 'utf8').split(/\r?\n/);
|
||||
const edits = [];
|
||||
const imports = new Set(['com.ruoyi.common.annotation.Log', 'com.ruoyi.common.enums.BusinessType']);
|
||||
for (const { method, attrs } of list) {
|
||||
// 找方法签名
|
||||
let sig = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (new RegExp(`^\\s*(?:public|protected|private)\\s+[\\w<>\\[\\],\\.\\? ]+\\s+${method}\\s*\\(`).test(lines[i])) { sig = i; break; }
|
||||
}
|
||||
if (sig < 0) { console.log(` [跳过] 找不到方法 ${path.basename(rel)}.${method}`); skipped++; continue; }
|
||||
// 注解块顶部
|
||||
let top = sig;
|
||||
for (let j = sig - 1; j >= 0; j--) {
|
||||
if (lines[j].trim() === '' || !isBlockMember(lines[j])) break;
|
||||
top = j;
|
||||
}
|
||||
// 已有 @Log 就整块删掉(支持多行)
|
||||
let start = -1, end = -1;
|
||||
for (let j = top; j < sig; j++) {
|
||||
if (/^\s*@Log\(/.test(lines[j])) {
|
||||
start = j;
|
||||
let depth = 0;
|
||||
outer:
|
||||
for (let k = j; k < sig; k++) {
|
||||
for (const ch of lines[k]) {
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')') { depth--; if (depth === 0) { end = k; break outer; } }
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
const indent = (lines[top].match(/^\s*/) || [' '])[0];
|
||||
// 自动认实体:入参里第一个"有 @TableName 且有对应 Mapper"的类型
|
||||
if (attrs.auto && !attrs.entity)
|
||||
{
|
||||
for (const t of methodTypes(lines, sig))
|
||||
{
|
||||
if (ENTITY.has(t) && MAPPER.has(t)) { attrs.entity = t; attrs.mapper = MAPPER.get(t).simple; break; }
|
||||
}
|
||||
}
|
||||
edits.push({ start, end, top, newLines: render(attrs, indent), method, attrs });
|
||||
if (attrs.entity && ENTITY.has(attrs.entity)) imports.add(ENTITY.get(attrs.entity));
|
||||
if (attrs.entity && MAPPER.has(attrs.entity)) imports.add(MAPPER.get(attrs.entity).fqn);
|
||||
if (attrs.mapper && IMPORTS[attrs.mapper]) imports.add(IMPORTS[attrs.mapper]);
|
||||
}
|
||||
for (const ed of edits.sort((a, b) => b.top - a.top)) {
|
||||
if (ed.start >= 0) lines.splice(ed.start, ed.end - ed.start + 1, ...ed.newLines);
|
||||
else lines.splice(ed.top, 0, ...ed.newLines);
|
||||
console.log(` ${path.basename(rel)}.${ed.method} → ${ed.attrs.menu} / ${ed.attrs.action}` +
|
||||
`${ed.attrs.entity ? ' diff=' + ed.attrs.entity : ''}${ed.attrs.idField ? ' idField=' + ed.attrs.idField : ''}`);
|
||||
done++;
|
||||
}
|
||||
insertImports(lines, imports);
|
||||
if (apply) fs.writeFileSync(abs, lines.join('\r\n'), 'utf8');
|
||||
}
|
||||
|
||||
console.log(`\n${apply ? 'APPLIED' : 'DRY-RUN'}: 写入 ${done} 个方法,跳过 ${skipped}`);
|
||||
@@ -0,0 +1,12 @@
|
||||
@echo off
|
||||
echo.
|
||||
echo [信息] 清理工程target生成路径。
|
||||
echo.
|
||||
|
||||
%~d0
|
||||
cd %~dp0
|
||||
|
||||
cd ..
|
||||
call mvn clean
|
||||
|
||||
pause
|
||||
@@ -0,0 +1,43 @@
|
||||
// 只读查询/受控清库小工具(连 docker 里的 mysql_db)
|
||||
// 用法:
|
||||
// node bin/logtool.js q "select count(*) from sys_oper_log"
|
||||
// node bin/logtool.js clear-operlog 清理操作日志(先备份到 bin/backup/)
|
||||
// node bin/logtool.js clear-operlog --yes 跳过确认
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const MYSQL = ['exec', 'mysql_db', 'mysql', '-uchangzhou', '-piTSJSPPZM3LSGAPC', '--default-character-set=utf8mb4', '-D', 'changzhou'];
|
||||
|
||||
function sql(statement) {
|
||||
return execFileSync('docker', [...MYSQL, '-N', '-B', '-e', statement],
|
||||
{ encoding: 'buffer', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8');
|
||||
}
|
||||
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
|
||||
if (cmd === 'q') {
|
||||
console.log(sql(rest.join(' ')));
|
||||
} else if (cmd === 'clear-operlog') {
|
||||
const before = sql('select count(*) from sys_oper_log').trim();
|
||||
console.log(`清空前 sys_oper_log: ${before} 行`);
|
||||
if (before === '0') process.exit(0);
|
||||
// 备份成 INSERT 语句,清错了还能捞回来
|
||||
const dir = path.resolve(__dirname, 'backup');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const dump = execFileSync('docker', [...MYSQL, '-N', '-B', '-e',
|
||||
'select concat("insert into sys_oper_log (title,business_type,method,request_method,operator_type,oper_name,dept_name,oper_url,oper_ip,oper_location,oper_param,json_result,status,error_msg,cost_time,oper_time) values (",' +
|
||||
'quote(title),",",business_type,",",quote(method),",",quote(request_method),",",operator_type,",",quote(oper_name),",",quote(dept_name),",",quote(oper_url),",",quote(oper_ip),",",quote(oper_location),",",quote(oper_param),",",quote(json_result),",",status,",",quote(error_msg),",",cost_time,",",quote(oper_time),");") from sys_oper_log'],
|
||||
{ encoding: 'buffer', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }).toString('utf8');
|
||||
const file = path.join(dir, `sys_oper_log_${new Date().toISOString().replace(/[:.]/g, '-')}.sql`);
|
||||
fs.writeFileSync(file, dump, 'utf8');
|
||||
console.log(`已备份 → ${file}`);
|
||||
if (!rest.includes('--yes')) {
|
||||
console.error('要真清空请加 --yes');
|
||||
process.exit(2);
|
||||
}
|
||||
sql('truncate table sys_oper_log');
|
||||
console.log(`清空后 sys_oper_log: ${sql('select count(*) from sys_oper_log').trim()} 行`);
|
||||
} else {
|
||||
console.log('用法: node bin/logtool.js q "<sql>" | node bin/logtool.js clear-operlog [--yes]');
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@echo off
|
||||
echo.
|
||||
echo [信息] 打包Web工程,生成war/jar包文件。
|
||||
echo.
|
||||
|
||||
%~d0
|
||||
cd %~dp0
|
||||
|
||||
cd ..
|
||||
call mvn clean package -Dmaven.test.skip=true
|
||||
|
||||
pause
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
@echo off
|
||||
echo.
|
||||
echo [信息] 使用Jar命令运行Web工程。
|
||||
echo.
|
||||
|
||||
cd %~dp0
|
||||
cd ../ruoyi-admin/target
|
||||
|
||||
set JAVA_OPTS=-Xms256m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m
|
||||
|
||||
java -jar %JAVA_OPTS% ruoyi-admin.jar --server.port=8081
|
||||
|
||||
cd bin
|
||||
pause
|
||||
@@ -0,0 +1,93 @@
|
||||
# Docker Windows 新手启动指南
|
||||
|
||||
本地 Windows 开发环境启动 MySQL / Redis / RabbitMQ 三个容器。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 已安装并启动 **Docker Desktop**(任务栏图标变绿才算启动完成)
|
||||
|
||||
## 首次启动(只做一次)
|
||||
|
||||
1. 创建 MySQL 数据卷:
|
||||
|
||||
```powershell
|
||||
docker volume create changzhou_mysql_data
|
||||
```
|
||||
|
||||
2. 启动容器(⚠️ 必须先 cd 到 compose 目录,且必须带两个 -f 参数):
|
||||
|
||||
```powershell
|
||||
cd d:\changzhou_app\changzhou_backend\docker\compose
|
||||
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
> ⚠️ **不要只执行 `docker-compose up -d`**!
|
||||
> 少了 `-f docker-compose.dev.yml` 会导致 Redis 容器找不到 redis.conf,
|
||||
> 状态一直是 Restarting 无限重启。
|
||||
> dev 覆盖文件负责把 `docker/config/redis/redis.dev.conf` 挂载进容器。
|
||||
|
||||
3. 验证:
|
||||
|
||||
```powershell
|
||||
docker ps
|
||||
# 三个容器都应为 Up 状态:mysql_db / redis_cache / rabbitmq_server
|
||||
|
||||
docker exec redis_cache redis-cli ping
|
||||
# 返回 NOAUTH Authentication required. 是正常的(Redis 已启动且带密码)
|
||||
```
|
||||
|
||||
## 端口对照表(宿主机 → 容器)
|
||||
|
||||
| 服务 | 本机地址 | 容器端口 | 账号信息 |
|
||||
|------|---------|---------|---------|
|
||||
| MySQL | 127.0.0.1:23306 | 3306 | root/123456,业务库 changzhou |
|
||||
| Redis | 127.0.0.1:26379 | 6379 | 密码见 redis.dev.conf |
|
||||
| RabbitMQ | 127.0.0.1:25672 | 5672 | skins/mk,vhost /skins |
|
||||
| RabbitMQ 管理台 | http://127.0.0.1:35672 | 15672 | skins/mk |
|
||||
|
||||
## 日常操作
|
||||
|
||||
```powershell
|
||||
# 都在 docker\compose 目录下执行
|
||||
cd d:\changzhou_app\changzhou_backend\docker\compose
|
||||
|
||||
# 启动
|
||||
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
|
||||
# 停止
|
||||
docker-compose -f docker-compose.yml -f docker-compose.dev.yml stop
|
||||
|
||||
# 查看状态
|
||||
docker ps
|
||||
|
||||
# 查看某个容器日志
|
||||
docker logs redis_cache --tail 50
|
||||
|
||||
# 重建某个容器(比如改了配置)
|
||||
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d --force-recreate redis
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. Redis 一直 Restarting
|
||||
|
||||
启动命令少了 `-f docker-compose.dev.yml`,重建即可:
|
||||
|
||||
```powershell
|
||||
cd d:\changzhou_app\changzhou_backend\docker\compose
|
||||
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d --force-recreate redis
|
||||
```
|
||||
|
||||
### 2. 提示 `external volume "changzhou_mysql_data" not found`
|
||||
|
||||
```powershell
|
||||
docker volume create changzhou_mysql_data
|
||||
```
|
||||
|
||||
### 3. 提示 `cannot find the file specified`
|
||||
|
||||
没有 cd 到 `docker\compose` 目录就执行了命令,`-f` 的相对路径按当前目录解析。
|
||||
|
||||
### 4. 和 chengdu 项目切换
|
||||
|
||||
端口可能冲突,先到另一个项目的 compose 目录执行 `docker-compose stop`,再启动本项目。
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# 自动创建 external 数据卷(首次部署时)
|
||||
for vol in changzhou_mysql_data; do
|
||||
docker volume inspect "$vol" >/dev/null 2>&1 || docker volume create "$vol" >/dev/null
|
||||
done
|
||||
|
||||
# 环境选择:ENV=dev 使用开发规格,否则按 CPU 核数自动检测
|
||||
if [ "${ENV:-prod}" = "dev" ]; then
|
||||
SIZE="dev"
|
||||
else
|
||||
CPUS=$(nproc)
|
||||
if [ "$CPUS" -le 16 ]; then
|
||||
SIZE="16c32g"
|
||||
else
|
||||
SIZE="32c64g"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 镜像源:默认海外,PROXY=cn 切换国内代理
|
||||
if [ "${PROXY:-overseas}" = "cn" ]; then
|
||||
export COMPOSE_FILE="docker-compose.yml:docker-compose.cn.yml:docker-compose.${SIZE}.yml"
|
||||
else
|
||||
export COMPOSE_FILE="docker-compose.yml:docker-compose.${SIZE}.yml"
|
||||
fi
|
||||
|
||||
echo "Env: ${ENV:-prod} | Size: ${SIZE} | Proxy: ${PROXY:-cn}"
|
||||
exec docker compose "$@"
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
mysql:
|
||||
build:
|
||||
args:
|
||||
MYSQL_CONF: my.cnf.16c32g
|
||||
shm_size: 128m
|
||||
mem_limit: 8g
|
||||
cpus: 4
|
||||
|
||||
redis:
|
||||
volumes:
|
||||
- ../config/redis/redis.16c32g.conf:/usr/local/etc/redis/redis.conf:ro
|
||||
mem_limit: 2g
|
||||
cpus: 1
|
||||
|
||||
rabbitmq:
|
||||
mem_limit: 2g
|
||||
cpus: 1
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
mysql:
|
||||
build:
|
||||
args:
|
||||
MYSQL_CONF: my.cnf.32c64g
|
||||
shm_size: 256m
|
||||
mem_limit: 12g
|
||||
cpus: 8
|
||||
|
||||
redis:
|
||||
volumes:
|
||||
- ../config/redis/redis.32c64g.conf:/usr/local/etc/redis/redis.conf:ro
|
||||
mem_limit: 4g
|
||||
cpus: 2
|
||||
|
||||
rabbitmq:
|
||||
mem_limit: 4g
|
||||
cpus: 2
|
||||
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
mysql:
|
||||
build:
|
||||
args:
|
||||
MYSQL_BASE_IMAGE: m.daocloud.io/docker.io/mysql:8.0.24
|
||||
|
||||
redis:
|
||||
image: m.daocloud.io/docker.io/redis:latest
|
||||
|
||||
rabbitmq:
|
||||
image: m.daocloud.io/docker.io/rabbitmq:3.9-management
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
mysql:
|
||||
build:
|
||||
args:
|
||||
MYSQL_CONF: my.cnf.dev
|
||||
shm_size: 64m
|
||||
mem_limit: 1g
|
||||
|
||||
redis:
|
||||
volumes:
|
||||
- ../config/redis/redis.dev.conf:/usr/local/etc/redis/redis.conf:ro
|
||||
mem_limit: 256m
|
||||
|
||||
rabbitmq:
|
||||
mem_limit: 256m
|
||||
@@ -0,0 +1,89 @@
|
||||
services:
|
||||
# MySQL 8.0.24 服务
|
||||
mysql:
|
||||
build:
|
||||
context: ../config/mysql
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
MYSQL_BASE_IMAGE: mysql:8.0.24
|
||||
MYSQL_CONF: my.cnf.16c32g
|
||||
container_name: mysql_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
MYSQL_ROOT_PASSWORD: 123456
|
||||
MYSQL_DATABASE: changzhou
|
||||
MYSQL_USER: changzhou
|
||||
MYSQL_PASSWORD: iTSJSPPZM3LSGAPC
|
||||
ports:
|
||||
- "127.0.0.1:23306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p123456"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
networks:
|
||||
- changzhou_app_network
|
||||
|
||||
# Redis 服务
|
||||
redis:
|
||||
image: redis:latest
|
||||
container_name: redis_cache
|
||||
restart: unless-stopped
|
||||
command: redis-server /usr/local/etc/redis/redis.conf
|
||||
ports:
|
||||
- "127.0.0.1:26379:6379"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- changzhou_app_network
|
||||
|
||||
# RabbitMQ 3.9 服务
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.9-management
|
||||
container_name: rabbitmq_server
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: skins
|
||||
RABBITMQ_DEFAULT_PASS: mk
|
||||
ports:
|
||||
- "127.0.0.1:25672:5672"
|
||||
- "127.0.0.1:35672:15672"
|
||||
command: >
|
||||
bash -c "rabbitmq-server &
|
||||
sleep 20 &&
|
||||
rabbitmqctl add_vhost /skins &&
|
||||
rabbitmqctl set_permissions -p /skins skins '.*' '.*' '.*' &&
|
||||
rabbitmqadmin --vhost=/skins --username=skins --password=mk declare queue name=notice_queue durable=true &&
|
||||
rabbitmqadmin --vhost=/skins --username=skins --password=mk declare queue name=dlk_queue durable=true &&
|
||||
rabbitmqadmin --vhost=/skins --username=skins --password=mk declare queue name=delivery_queue durable=true &&
|
||||
wait"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- changzhou_app_network
|
||||
|
||||
# 定义数据卷(external 固定卷名,不随目录变化,防止换目录丢数据)
|
||||
volumes:
|
||||
mysql_data:
|
||||
external: true
|
||||
name: changzhou_mysql_data
|
||||
|
||||
# 定义网络
|
||||
networks:
|
||||
changzhou_app_network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
# MySQL 数据卷迁移脚本
|
||||
# 将旧自动命名卷的数据复制到 external 卷 changzhou_mysql_data
|
||||
# 用法:bash migrate-mysql-volume.sh [旧卷名]
|
||||
# 不传参数时自动检测 docker_changzhou_mysql_data 和 docker-cn_changzhou_mysql_data
|
||||
|
||||
set -e
|
||||
|
||||
NEW_VOL="changzhou_mysql_data"
|
||||
IMAGE="mysql:8.0.24"
|
||||
|
||||
# 自动检测旧卷名
|
||||
if [ -n "$1" ]; then
|
||||
OLD_VOL="$1"
|
||||
else
|
||||
for candidate in docker_changzhou_mysql_data docker-cn_changzhou_mysql_data; do
|
||||
if docker volume inspect "$candidate" >/dev/null 2>&1; then
|
||||
OLD_VOL="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$OLD_VOL" ]; then
|
||||
echo "ERROR: 未找到旧数据卷,请手动指定:bash migrate-mysql-volume.sh <旧卷名>"
|
||||
echo "可用的卷列表:"
|
||||
docker volume ls --format ' {{.Name}}' | grep -i mysql || echo " (无 mysql 相关卷)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查新卷是否存在
|
||||
if ! docker volume inspect "$NEW_VOL" >/dev/null 2>&1; then
|
||||
echo "创建 external 卷: $NEW_VOL"
|
||||
docker volume create "$NEW_VOL"
|
||||
fi
|
||||
|
||||
# 检查旧卷数据大小
|
||||
OLD_SIZE=$(docker run --rm -v "${OLD_VOL}:/data" "$IMAGE" sh -c "du -sh /data" 2>/dev/null | awk '{print $1}')
|
||||
echo "旧卷 $OLD_VOL 数据量: $OLD_SIZE"
|
||||
|
||||
# 停掉 MySQL 容器(如果在运行)
|
||||
if docker inspect mysql_db >/dev/null 2>&1; then
|
||||
echo "停止 MySQL 容器..."
|
||||
docker stop mysql_db 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 清空新卷(避免数据叠加)
|
||||
echo "清空新卷 $NEW_VOL ..."
|
||||
docker volume rm "$NEW_VOL" >/dev/null
|
||||
docker volume create "$NEW_VOL" >/dev/null
|
||||
|
||||
# 复制数据
|
||||
echo "开始迁移 $OLD_VOL -> $NEW_VOL ..."
|
||||
docker run --rm \
|
||||
-v "${OLD_VOL}:/from" \
|
||||
-v "${NEW_VOL}:/to" \
|
||||
"$IMAGE" \
|
||||
sh -c "cp -a /from/. /to/"
|
||||
|
||||
# 验证
|
||||
NEW_SIZE=$(docker run --rm -v "${NEW_VOL}:/data" "$IMAGE" sh -c "du -sh /data" 2>/dev/null | awk '{print $1}')
|
||||
echo "新卷 $NEW_VOL 数据量: $NEW_SIZE"
|
||||
echo "迁移完成!旧卷 $OLD_VOL 保留作为备份。"
|
||||
@@ -0,0 +1,5 @@
|
||||
ARG MYSQL_BASE_IMAGE=mysql:8.0.24
|
||||
FROM ${MYSQL_BASE_IMAGE}
|
||||
|
||||
ARG MYSQL_CONF=my.cnf.16c32g
|
||||
COPY ${MYSQL_CONF} /etc/mysql/conf.d/my.cnf
|
||||
@@ -0,0 +1,32 @@
|
||||
[mysqld]
|
||||
# ===== 网络 =====
|
||||
skip-host-cache
|
||||
skip-name-resolve
|
||||
|
||||
# ===== InnoDB 核心性能(16C/32G 服务器 / SSD) =====
|
||||
innodb_buffer_pool_size = 5G
|
||||
innodb_flush_method = O_DIRECT
|
||||
innodb_flush_log_at_trx_commit = 2
|
||||
innodb_log_file_size = 512M
|
||||
innodb_log_files_in_group = 2
|
||||
innodb_log_buffer_size = 32M
|
||||
innodb_io_capacity = 1000
|
||||
innodb_io_capacity_max = 4000
|
||||
innodb_read_io_threads = 4
|
||||
innodb_write_io_threads = 4
|
||||
|
||||
# ===== 禁用 binlog(无需主从复制) =====
|
||||
skip-log-bin
|
||||
|
||||
# ===== 连接数 =====
|
||||
max_connections = 200
|
||||
max_user_connections = 180
|
||||
|
||||
# ===== 临时表 =====
|
||||
tmp_table_size = 32M
|
||||
max_heap_table_size = 32M
|
||||
|
||||
# ===== 慢查询日志 =====
|
||||
slow_query_log = ON
|
||||
slow_query_log_file = /var/lib/mysql/slow.log
|
||||
long_query_time = 1
|
||||
@@ -0,0 +1,32 @@
|
||||
[mysqld]
|
||||
# ===== 网络 =====
|
||||
skip-host-cache
|
||||
skip-name-resolve
|
||||
|
||||
# ===== InnoDB 核心性能(32C/64G 服务器 / SSD) =====
|
||||
innodb_buffer_pool_size = 8G
|
||||
innodb_flush_method = O_DIRECT
|
||||
innodb_flush_log_at_trx_commit = 2
|
||||
innodb_log_file_size = 1G
|
||||
innodb_log_files_in_group = 2
|
||||
innodb_log_buffer_size = 64M
|
||||
innodb_io_capacity = 1000
|
||||
innodb_io_capacity_max = 4000
|
||||
innodb_read_io_threads = 8
|
||||
innodb_write_io_threads = 8
|
||||
|
||||
# ===== 禁用 binlog(无需主从复制) =====
|
||||
skip-log-bin
|
||||
|
||||
# ===== 连接数 =====
|
||||
max_connections = 300
|
||||
max_user_connections = 280
|
||||
|
||||
# ===== 临时表 =====
|
||||
tmp_table_size = 64M
|
||||
max_heap_table_size = 64M
|
||||
|
||||
# ===== 慢查询日志 =====
|
||||
slow_query_log = ON
|
||||
slow_query_log_file = /var/lib/mysql/slow.log
|
||||
long_query_time = 1
|
||||
@@ -0,0 +1,31 @@
|
||||
[mysqld]
|
||||
# ===== 网络 =====
|
||||
skip-host-cache
|
||||
skip-name-resolve
|
||||
|
||||
# ===== InnoDB 核心性能(开发环境) =====
|
||||
innodb_buffer_pool_size = 256M
|
||||
innodb_flush_log_at_trx_commit = 2
|
||||
innodb_log_file_size = 128M
|
||||
innodb_log_files_in_group = 2
|
||||
innodb_log_buffer_size = 8M
|
||||
innodb_io_capacity = 1000
|
||||
innodb_io_capacity_max = 2000
|
||||
innodb_read_io_threads = 4
|
||||
innodb_write_io_threads = 4
|
||||
|
||||
# ===== 禁用 binlog =====
|
||||
skip-log-bin
|
||||
|
||||
# ===== 连接数 =====
|
||||
max_connections = 30
|
||||
max_user_connections = 25
|
||||
|
||||
# ===== 临时表 =====
|
||||
tmp_table_size = 32M
|
||||
max_heap_table_size = 32M
|
||||
|
||||
# ===== 慢查询日志 =====
|
||||
slow_query_log = ON
|
||||
slow_query_log_file = /var/lib/mysql/slow.log
|
||||
long_query_time = 1
|
||||
@@ -0,0 +1,46 @@
|
||||
# Redis configuration file
|
||||
bind 0.0.0.0
|
||||
port 6379
|
||||
timeout 300
|
||||
tcp-keepalive 300
|
||||
daemonize no
|
||||
pidfile /var/run/redis.pid
|
||||
loglevel notice
|
||||
logfile stdout
|
||||
databases 16
|
||||
save 900 1
|
||||
save 300 10
|
||||
save 60 10000
|
||||
stop-writes-on-bgsave-error yes
|
||||
rdbcompression yes
|
||||
rdbchecksum yes
|
||||
dbfilename dump.rdb
|
||||
dir /data
|
||||
requirepass FireSkins@99999
|
||||
masterauth FireSkins@99999
|
||||
rename-command FLUSHDB ""
|
||||
rename-command FLUSHALL ""
|
||||
maxclients 10000
|
||||
maxmemory 1gb
|
||||
maxmemory-policy volatile-lru
|
||||
appendonly no
|
||||
appendfilename "appendonly.aof"
|
||||
appendfsync everysec
|
||||
no-appendfsync-on-rewrite no
|
||||
auto-aof-rewrite-percentage 100
|
||||
auto-aof-rewrite-min-size 64mb
|
||||
lua-time-limit 5000
|
||||
slowlog-log-slower-than 10000
|
||||
slowlog-max-len 128
|
||||
hash-max-listpack-entries 512
|
||||
hash-max-listpack-value 64
|
||||
list-max-listpack-size -2
|
||||
set-max-intset-entries 512
|
||||
set-max-listpack-entries 128
|
||||
set-max-listpack-value 64
|
||||
zset-max-listpack-entries 128
|
||||
zset-max-listpack-value 64
|
||||
client-output-buffer-limit normal 0 0 0
|
||||
client-output-buffer-limit slave 256mb 64mb 60
|
||||
client-output-buffer-limit pubsub 32mb 8mb 60
|
||||
hz 10
|
||||
@@ -0,0 +1,46 @@
|
||||
# Redis configuration file
|
||||
bind 0.0.0.0
|
||||
port 6379
|
||||
timeout 300
|
||||
tcp-keepalive 300
|
||||
daemonize no
|
||||
pidfile /var/run/redis.pid
|
||||
loglevel notice
|
||||
logfile stdout
|
||||
databases 16
|
||||
save 900 1
|
||||
save 300 10
|
||||
save 60 10000
|
||||
stop-writes-on-bgsave-error yes
|
||||
rdbcompression yes
|
||||
rdbchecksum yes
|
||||
dbfilename dump.rdb
|
||||
dir /data
|
||||
requirepass FireSkins@99999
|
||||
masterauth FireSkins@99999
|
||||
rename-command FLUSHDB ""
|
||||
rename-command FLUSHALL ""
|
||||
maxclients 10000
|
||||
maxmemory 2gb
|
||||
maxmemory-policy volatile-lru
|
||||
appendonly no
|
||||
appendfilename "appendonly.aof"
|
||||
appendfsync everysec
|
||||
no-appendfsync-on-rewrite no
|
||||
auto-aof-rewrite-percentage 100
|
||||
auto-aof-rewrite-min-size 64mb
|
||||
lua-time-limit 5000
|
||||
slowlog-log-slower-than 10000
|
||||
slowlog-max-len 128
|
||||
hash-max-listpack-entries 512
|
||||
hash-max-listpack-value 64
|
||||
list-max-listpack-size -2
|
||||
set-max-intset-entries 512
|
||||
set-max-listpack-entries 128
|
||||
set-max-listpack-value 64
|
||||
zset-max-listpack-entries 128
|
||||
zset-max-listpack-value 64
|
||||
client-output-buffer-limit normal 0 0 0
|
||||
client-output-buffer-limit slave 256mb 64mb 60
|
||||
client-output-buffer-limit pubsub 32mb 8mb 60
|
||||
hz 10
|
||||
@@ -0,0 +1,46 @@
|
||||
# Redis configuration file
|
||||
bind 0.0.0.0
|
||||
port 6379
|
||||
timeout 300
|
||||
tcp-keepalive 300
|
||||
daemonize no
|
||||
pidfile /var/run/redis.pid
|
||||
loglevel notice
|
||||
logfile stdout
|
||||
databases 16
|
||||
save 900 1
|
||||
save 300 10
|
||||
save 60 10000
|
||||
stop-writes-on-bgsave-error yes
|
||||
rdbcompression yes
|
||||
rdbchecksum yes
|
||||
dbfilename dump.rdb
|
||||
dir /data
|
||||
requirepass FireSkins@99999
|
||||
masterauth FireSkins@99999
|
||||
rename-command FLUSHDB ""
|
||||
rename-command FLUSHALL ""
|
||||
maxclients 10000
|
||||
maxmemory 128mb
|
||||
maxmemory-policy volatile-lru
|
||||
appendonly no
|
||||
appendfilename "appendonly.aof"
|
||||
appendfsync everysec
|
||||
no-appendfsync-on-rewrite no
|
||||
auto-aof-rewrite-percentage 100
|
||||
auto-aof-rewrite-min-size 64mb
|
||||
lua-time-limit 5000
|
||||
slowlog-log-slower-than 10000
|
||||
slowlog-max-len 128
|
||||
hash-max-listpack-entries 512
|
||||
hash-max-listpack-value 64
|
||||
list-max-listpack-size -2
|
||||
set-max-intset-entries 512
|
||||
set-max-listpack-entries 128
|
||||
set-max-listpack-value 64
|
||||
zset-max-listpack-entries 128
|
||||
zset-max-listpack-value 64
|
||||
client-output-buffer-limit normal 0 0 0
|
||||
client-output-buffer-limit slave 256mb 64mb 60
|
||||
client-output-buffer-limit pubsub 32mb 8mb 60
|
||||
hz 10
|
||||
@@ -0,0 +1,69 @@
|
||||
# Docker 基础设施
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
docker/
|
||||
├── compose/ # Docker Compose 文件 + 脚本
|
||||
│ ├── docker-compose.yml # 主文件 (MySQL + Redis + RabbitMQ)
|
||||
│ ├── docker-compose.cn.yml # 国内镜像源 override
|
||||
│ ├── docker-compose.dev.yml # 开发环境资源限制
|
||||
│ ├── docker-compose.16c32g.yml # 16C/32G 服务器资源规格
|
||||
│ ├── docker-compose.32c64g.yml # 32C/64G 服务器资源规格
|
||||
│ ├── deploy.sh # 智能部署脚本
|
||||
│ └── migrate-mysql-volume.sh # 数据卷迁移脚本
|
||||
├── config/ # 服务配置文件
|
||||
│ ├── mysql/
|
||||
│ │ ├── Dockerfile # 参数化 MySQL 镜像
|
||||
│ │ ├── my.cnf.dev # 开发环境配置
|
||||
│ │ ├── my.cnf.16c32g # 16C/32G 配置
|
||||
│ │ └── my.cnf.32c64g # 32C/64G 配置
|
||||
│ └── redis/
|
||||
│ ├── redis.dev.conf # 开发环境配置
|
||||
│ ├── redis.16c32g.conf # 16C/32G 配置
|
||||
│ └── redis.32c64g.conf # 32C/64G 配置
|
||||
└── readme.md # 本文件
|
||||
```
|
||||
|
||||
## 快速使用
|
||||
|
||||
```bash
|
||||
cd docker/compose
|
||||
|
||||
# 自动检测环境并启动(默认:国内镜像 + 自动检测 CPU 规格)
|
||||
./deploy.sh up -d
|
||||
|
||||
# 开发环境
|
||||
ENV=dev ./deploy.sh up -d
|
||||
|
||||
# 海外镜像源
|
||||
PROXY=overseas ./deploy.sh up -d
|
||||
|
||||
# 停止
|
||||
./deploy.sh stop
|
||||
|
||||
# 删除容器
|
||||
./deploy.sh down
|
||||
|
||||
# 重新创建某个容器
|
||||
./deploy.sh up -d --build --force-recreate rabbitmq
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `ENV` | `prod` | `dev` 使用开发资源规格,`prod` 按 CPU 核数自动检测 |
|
||||
| `PROXY` | `cn` | `cn` 使用国内镜像源,`overseas` 使用官方源 |
|
||||
| `JVM_PROFILE` | (自动) | 可手动指定 `16c32g` 或 `32c64g` |
|
||||
|
||||
## 首次部署:数据卷迁移
|
||||
|
||||
如果之前使用旧版 docker-compose(自动命名卷),需要迁移数据到 external 卷:
|
||||
|
||||
```bash
|
||||
cd docker/compose
|
||||
bash migrate-mysql-volume.sh
|
||||
# 或手动指定旧卷名
|
||||
bash migrate-mysql-volume.sh docker_chengdu_mysql_data
|
||||
```
|
||||
@@ -0,0 +1,201 @@
|
||||
# Skins 业务后端 — 详细部署文档
|
||||
|
||||
> 适用项目:chengdu-backend(基于若依 RuoYi 开发,Spring Boot 4.0.3 + JDK 21)
|
||||
> 适用场景:本地开发、测试环境、生产环境
|
||||
> 项目根 README 见 [README.md](README.md),本文为补充的详细部署手册
|
||||
|
||||
## 1. 部署架构总览
|
||||
|
||||
- 后端单进程:ruoyi-admin.jar,监听28081
|
||||
- 基础设施:MySQL 8.x、Redis、RabbitMQ(参考 docker/docker-compose.yml)
|
||||
- 前端:ruoyi-ui(Vue2 经典管理端),promo-ui2(Vue3 主播后台),cocos前端
|
||||
|
||||
|
||||
## 2. 环境准备
|
||||
|
||||
### 2.1 必备软件
|
||||
|
||||
JDK 21(项目使用 /root/jdk-21.0.10) 必须为 JDK 21
|
||||
Maven 3.6.x(/root/apache-maven-3.6.3)
|
||||
|
||||
### 2.2 安装示例
|
||||
|
||||
# JDK 21
|
||||
cd /root
|
||||
# 上传 jdk-21.x.tar.gz 到 /root
|
||||
tar -xzf jdk-21.0.10_linux-x64_bin.tar.gz
|
||||
mv jdk-21.0.10 /root/jdk-21.0.10
|
||||
|
||||
# Maven
|
||||
cd /root
|
||||
tar -xzf apache-maven-3.6.3-bin.tar.gz
|
||||
|
||||
# 配置全局环境变量(在脚本内配置,见 ry.sh / package.sh)
|
||||
cat >> /etc/profile <<'EOF'
|
||||
export JAVA_HOME=/root/jdk-21.0.10
|
||||
export MAVEN_HOME=/root/apache-maven-3.6.3
|
||||
export PATH=$JAVA_HOME/bin:$MAVEN_HOME/bin:$PATH
|
||||
EOF
|
||||
source /etc/profile
|
||||
|
||||
# 验证
|
||||
java -version
|
||||
mvn -v
|
||||
|
||||
|
||||
## 3. 基础设施部署(MySQL / Redis / RabbitMQ)
|
||||
|
||||
### 3.1 使用仓库自带 docker-compose
|
||||
|
||||
仓库 docker/ 目录下已提供文件,采用 compose override 模式,通过 deploy.sh 自动选择镜像源和资源规格。
|
||||
|
||||
bash
|
||||
cd chengdu-backend/docker/compose
|
||||
|
||||
# 自动检测环境并启动(默认:国内镜像 + 自动检测 CPU 规格)
|
||||
./deploy.sh up -d
|
||||
|
||||
# 开发环境
|
||||
ENV=dev ./deploy.sh up -d
|
||||
|
||||
# 海外镜像源(如国内镜像不可用)
|
||||
PROXY=overseas ./deploy.sh up -d
|
||||
|
||||
# 停止 / 删除
|
||||
./deploy.sh stop
|
||||
./deploy.sh down
|
||||
|
||||
容器常用操作:
|
||||
|
||||
./deploy.sh up -d # 启动
|
||||
./deploy.sh stop # 停止
|
||||
|
||||
### 3.2 首次部署:数据卷迁移
|
||||
|
||||
如果之前使用旧版 docker-compose(自动命名卷),需要迁移数据到 external 卷:
|
||||
|
||||
bash
|
||||
cd docker/compose
|
||||
bash migrate-mysql-volume.sh
|
||||
|
||||
|
||||
## 4. 后端配置yml说明(Spring Profile)
|
||||
|
||||
入口配置文件:[ruoyi-admin/src/main/resources/application.yml](ruoyi-admin/src/main/resources/application.yml)
|
||||
|
||||
### 4.1 Profile 文件
|
||||
|
||||
| Profile | 文件
|
||||
| dev | application-dev.yml
|
||||
| pro | application-pro.yml
|
||||
|
||||
### 4.2 生产关键配置(节选自 application-pro.yml)
|
||||
|
||||
yaml
|
||||
ruoyi:
|
||||
profile: /www/static # 上传文件存储目录
|
||||
demoEnabled: true
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
druid:
|
||||
master:
|
||||
url: jdbc:mysql://127.0.0.1:23306/chengdu?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
|
||||
username: chengdu
|
||||
password: <数据库密码>
|
||||
data:
|
||||
redis:
|
||||
host: 127.0.0.1
|
||||
port: 26379
|
||||
password: <Redis 密码>
|
||||
rabbitmq:
|
||||
host: 127.0.0.1
|
||||
port: 25672
|
||||
username: skins
|
||||
password: <RabbitMQ 密码>
|
||||
virtual-host: /skins
|
||||
|
||||
token:
|
||||
secret: <强随机 32 位以上字符串> # JWT 密钥,必须修改
|
||||
expireTime: 1440 # 分钟
|
||||
|
||||
### 4.3 项目文件上传目录
|
||||
|
||||
ruoyi.profile 指上传文件物理路径(默认 /www/static),创建并赋权(宝塔直接手动创建):
|
||||
|
||||
|
||||
## 5. 编译与打包
|
||||
### 5.1 服务器编译(脚本封装)
|
||||
|
||||
仓库根目录提供 [package.sh](package.sh):
|
||||
|
||||
package.sh
|
||||
主要工作:
|
||||
1. 检查 mvn 和 java 命令是否可用。
|
||||
2. git pull 拉取最新代码。
|
||||
3. 调用 mvn clean package -Dmaven.test.skip=true。
|
||||
4. 将 ruoyi-admin/target/ruoyi-admin.jar 复制到仓库根目录 ./ruoyi-admin.jar,供 ry.sh 启动。
|
||||
|
||||
|
||||
## 6. 一键部署脚本(宝塔推荐)
|
||||
|
||||
仓库根 [publish.sh](publish.sh) 封装「备份旧jar → 打包 → 重启」完整流程:
|
||||
|
||||
bash执行流程
|
||||
cd /www/chengdu_app/chengdu-backend
|
||||
./publish.sh
|
||||
|
||||
执行解释步骤:
|
||||
1. 备份旧 jar:cp ruoyi-admin.jar ruoyi-admin.jar.bak
|
||||
2. 打包:调用 ./package.sh(含 git pull + mvn package)
|
||||
3. 重启:./ry.sh stop && ./ry.sh start
|
||||
|
||||
ry.sh 会自动检测 CPU 核数选择 JVM 规格(16c32g: 10g堆 / 32c64g: 22g堆),使用 G1GC 并开启 GC 日志。可通过 JVM_PROFILE 环境变量覆盖规格,SPRING_PROFILE 覆盖 Spring Profile(默认 pro)。
|
||||
|
||||
|
||||
## 7. 前端工程部署
|
||||
|
||||
### 7.1 ruoyi-ui(Vue2 经典管理端)
|
||||
|
||||
bash
|
||||
cd ruoyi-ui
|
||||
npm install
|
||||
npm run build:prod # 将dist目录下文件打包
|
||||
# 将 dist压缩包 上传到/www/wwwroot/静态目录
|
||||
|
||||
|
||||
### 7.2 promo-ui2(Vue3 推广后台)
|
||||
|
||||
bash
|
||||
cd promo-ui2
|
||||
pnpm install
|
||||
pnpm build:pro # 将dist目录下文件打包
|
||||
# 将 dist压缩包 上传到/www/wwwroot/静态目录
|
||||
|
||||
|
||||
## 8. 宝塔端口放行
|
||||
|
||||
| 端口 | 用途
|
||||
| 80 / 443 | Nginx
|
||||
| 28081 | 后端 Spring Boot
|
||||
| 23306 | MySQL
|
||||
| 26379 | Redis
|
||||
| 25672 / 35672 | RabbitMQ AMQP
|
||||
|
||||
执行流程
|
||||
宝塔 → 安全 → 放行 80/443/26379/23306/28081 等等所需要的端口号,其余端口可在宝塔安全设置添加和修改。
|
||||
|
||||
## 9. 日志与监控
|
||||
|
||||
### 9.1 日志文件
|
||||
|
||||
| 路径 | 内容 |
|
||||
| nohup.out | 启动 + 控制台日志(由 ry.sh 输出) |
|
||||
| /www/ruoyi/logs/sys-info.log | 系统日志(logback,按天滚动,保留60天) |
|
||||
| /www/ruoyi/logs/sys-error.log | 错误日志(logback,按天滚动,保留60天) |
|
||||
| /www/ruoyi/logs/gc.log | GC 日志(滚动 10 个文件,每个 50M) |
|
||||
|
||||
实时跟踪:
|
||||
bash脚本
|
||||
tail -f nohup.out
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
# Skins 业务后端
|
||||
|
||||
|
||||
基于 **若依(RuoYi)4.8.2** 扩展的多模块 Java 后端,集成 CSGO/饰品相关业务(用户、玩法、第三方支付与回调、定时任务、推广后台等)。仓库同时包含两套管理端前端工程。
|
||||
|
||||
克隆后请在项目根目录执行 `mvn -v` 确认 JDK 21 与 Maven 可用,再按下文完成数据库与中间件初始化。
|
||||
|
||||
---
|
||||
|
||||
## 技术栈概览
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 运行时 | Java **21** |
|
||||
| 框架 | Spring Boot **4.0.3**(启用虚拟线程 `spring.threads.virtual.enabled`) |
|
||||
| Web 与安全 | **Spring Security 6**(无状态会话)、JWT(`Authorization` 请求头)、若依式菜单/权限模型 |
|
||||
| 持久层 | MyBatis / MyBatis-Plus、PageHelper、Druid(含多数据源扩展) |
|
||||
| 数据库 | MySQL |
|
||||
| 缓存 | Redis(Spring Data Redis + Lettuce) |
|
||||
| 消息 | RabbitMQ(业务侧配置虚拟主机等,详见各 profile) |
|
||||
| 实时 | WebSocket(如 `/ws/**` 等路径,与安全放行配置保持一致) |
|
||||
| API 文档 | **Springfox 3.x**(主用,Swagger / OAS3 分组);另引入 **springdoc-openapi** Starter,可与 Springfox 并行存在 |
|
||||
|
||||
---
|
||||
|
||||
## 仓库结构
|
||||
|
||||
```
|
||||
chengdu-backend/
|
||||
├── ruoyi-admin/ # Web 入口(Spring Boot 启动模块)
|
||||
├── ruoyi-framework/ # 安全、过滤器、资源配置等封装
|
||||
├── ruoyi-system/ # 系统管理(用户、角色、菜单等)
|
||||
├── ruoyi-common/ # 通用工具、常量、MQ 配置等
|
||||
├── ruoyi-quartz/ # 定时任务(若依 Quartz 集成)
|
||||
├── ruoyi-generator/ # 代码生成
|
||||
├── skins-model/ # 业务领域模型(实体等)
|
||||
├── skins-service/ # 业务服务聚合 POM(子模块见下)
|
||||
│ ├── service-admin/ # 运营管理侧接口
|
||||
│ ├── service-user/ # 用户侧接口
|
||||
│ ├── service-playingmethod/ # 玩法相关
|
||||
│ ├── service-thirdparty/ # 第三方对接
|
||||
│ └── service-task/ # 任务与调度配合
|
||||
├── skins-promo/ # 推广后台 API(路径前缀多为 /promo)
|
||||
├── ruoyi-ui/ # 管理端 Vue2 + Element UI(若依经典前端)
|
||||
├── promo-ui2/ # 推广/运营后台 Vue3 + Element Plus + Vite
|
||||
└── docker/ # MySQL / Redis / RabbitMQ 编排
|
||||
```
|
||||
|
||||
### 启动类
|
||||
|
||||
后端入口:`ruoyi-admin` 模块中的 `com.ruoyi.RuoYiApplication`。使用 `@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })` 排除 Spring Boot 默认数据源自动配置(Spring Boot 4 下为 `org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration`),由若依多数据源与 Druid 配置接管。
|
||||
|
||||
---
|
||||
|
||||
## 运行环境与配置
|
||||
|
||||
### 必备依赖
|
||||
|
||||
- JDK **21**
|
||||
- Maven **3.6+**
|
||||
- MySQL **8.x**(库名与账号见各 profile;本地可参考 `docker/docker-compose.yml`)
|
||||
- Redis
|
||||
- RabbitMQ(若启用了 MQ 监听或相关消费者则需可用)
|
||||
|
||||
根 `pom.xml` 已配置 **阿里云 Maven 公共仓库**,便于国内环境拉取依赖。
|
||||
|
||||
### Spring Profile
|
||||
|
||||
`ruoyi-admin/src/main/resources/application.yml` 中通过 `spring.profiles.active` 切换环境,配套文件:
|
||||
|
||||
- `application-dev.yml` — 本地/开发
|
||||
- `application-test.yml` — 测试
|
||||
- `application-pro.yml` — 生产
|
||||
|
||||
**重要:** 上述 profile 文件中常含数据库口令、第三方支付与短信密钥等敏感信息。**勿将真实密钥提交到公开仓库**;上线请改用环境变量、密钥管理系统或独立于仓库的配置挂载。
|
||||
|
||||
### 默认服务端口与上下文
|
||||
|
||||
- HTTP 端口:**28081**(见根配置 `ruoyi-admin/src/main/resources/application.yml` 中的 `server.port`;各环境可被 profile 或部署参数覆盖,默认绑定 `0.0.0.0`)
|
||||
- 上下文路径:`/`(与管理端、`VUE_APP_BASE_API` 及反向代理前缀需一致)
|
||||
|
||||
### 对外匿名接口(简述)
|
||||
|
||||
与安全配置对齐的常见分类包括:`/login`、`/register`、`/captchaImage`,以及业务侧的 `/api/**`、`/admin/**`、WebSocket、部分 Swagger 与 Druid 监控路径等。具体请以 `ruoyi-framework` 中 **SecurityFilterChain** 与白名单配置为准。
|
||||
|
||||
---
|
||||
|
||||
## 本地启动后端
|
||||
|
||||
**方式一:先打包再在启动模块运行**
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bash
|
||||
mvn clean package -DskipTests
|
||||
cd ruoyi-admin
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
**方式二:根目录一键拉起(会解析 `ruoyi-admin` 所需模块依赖)**
|
||||
|
||||
```bash
|
||||
mvn -pl ruoyi-admin -am spring-boot:run -DskipTests
|
||||
```
|
||||
|
||||
也可在 IDE 中直接运行 `RuoYiApplication`。
|
||||
|
||||
首次运行前请:
|
||||
|
||||
1. 创建 MySQL 库并完成若依及业务所需的 SQL 初始化(以团队提供的脚本为准)。
|
||||
2. 按所选 profile 修改数据源、Redis、RabbitMQ 等连接信息。
|
||||
|
||||
---
|
||||
|
||||
## Docker 基础设施
|
||||
|
||||
目录:`docker/`
|
||||
|
||||
- `docker-compose.yml`:MySQL(含自定义镜像)、Redis、RabbitMQ(管理端口及初始化示例)。
|
||||
- `docker/docker-cn/`:面向大陆镜像拉取环境的编排变体。
|
||||
|
||||
常用操作见 **`docker/readme.md`**(如单独重建某个服务等)。
|
||||
|
||||
---
|
||||
|
||||
## 前端工程
|
||||
|
||||
### ruoyi-ui(Vue 2)
|
||||
|
||||
- 技术栈:Vue CLI、Element UI(`package.json` 声明 Node `>=8.9`,实际开发建议 Node **16 LTS** 或 **18 LTS**,并按需开启 OpenSSL 兼容)。
|
||||
- 脚本见 `ruoyi-ui/package.json`(Windows 脚本含 `set NODE_OPTIONS=--openssl-legacy-provider`;在 macOS/Linux 上需 `export NODE_OPTIONS=...` 或使用等价脚本)。
|
||||
- 开发与打包:`npm run dev`、`npm run build:prod` 等。
|
||||
- 系统工具内「Swagger」页当前 iframe 指向 **`/doc.html`**;若后端未部署对应聚合页,请改为与下文 **API 文档** 中实际地址一致(例如 `/swagger-ui.html`)。
|
||||
|
||||
### promo-ui2(Vue 3)
|
||||
|
||||
- 技术栈:Vue 3、Vite、TypeScript、Element Plus(基于 vue-element-plus-admin 演进);要求 **Node ≥ 18**、`pnpm ≥ 8.1`(以 `promo-ui2/package.json` 中 `engines` 为准)。
|
||||
- 推荐使用 **pnpm**:`pnpm install`、`pnpm dev`、`pnpm build:pro` 等。
|
||||
- 详细说明见 `promo-ui2/README.zh-CN.md`。
|
||||
|
||||
前端请求的接口基地址需指向本后端;若经 Nginx 网关,注意与团队约定的前缀(如 `/prod-api`)一致。
|
||||
|
||||
---
|
||||
|
||||
## 业务模块说明(简述)
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| **skins-model** | 共享实体与领域常量 |
|
||||
| **service-user** | 用户相关业务 |
|
||||
| **service-admin** | 运营管理侧业务 |
|
||||
| **service-playingmethod** | 玩法相关逻辑 |
|
||||
| **service-thirdparty** | 第三方对接(支付、交易平台回调等,密钥在各 profile 中) |
|
||||
| **service-task** | 异步任务 / 与 Quartz 等协同 |
|
||||
| **skins-promo** | 推广后台:登录注册、流水与佣金、充值卡等(REST 前缀示例:`/promo`) |
|
||||
|
||||
---
|
||||
|
||||
## API 文档
|
||||
|
||||
- **Springfox(当前主要使用)**:启动后访问(端口与 `server.port` 一致,仓库默认 **28081**)
|
||||
`http://localhost:28081/swagger-ui.html`
|
||||
在页面右上角切换分组(如 `user`、`thirdParty`、`playingMethod`、`task`、`promo` 等;与 `SwaggerConfig` 中 Docket 一致)。OpenAPI JSON 路径形如 `/<group>/api-docs`(Spring Security 已对 `/*/api-docs` 等放行,以实际配置为准)。
|
||||
- **springdoc-openapi**:`ruoyi-admin` 已引入依赖;若单独启用 SpringDoc UI,需确保 **Security** 对 `/swagger-ui/**`、`/v3/api-docs` 等路径放行,避免 401。
|
||||
|
||||
---
|
||||
|
||||
## 版本信息
|
||||
|
||||
- 父 POM:`com.ruoyi:ruoyi:4.8.2`(`spring-boot.version` **4.0.3**,`springdoc.version` **3.0.2**,`swagger` / Springfox **3.0.0**)
|
||||
- 若依官网参考:<http://www.ruoyi.vip>
|
||||
|
||||
---
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. 轮换并私有化所有第三方(支付宝、聚合支付、短信等)密钥;生产环境勿使用仓库内示例明文。
|
||||
2. 修改 JWT `token.secret` 与 Druid 监控口令等非默认值。
|
||||
3. 按需收紧 Druid、`/actuator`(若启用)及 Swagger 相关路径的访问范围。
|
||||
|
||||
如有与本说明不一致之处,以当前仓库代码及团队部署文档为准。
|
||||
@@ -0,0 +1,160 @@
|
||||
-- ============================================================
|
||||
-- 常州项目 数据库清理脚本(TRUNCATE 各类业务记录表)
|
||||
-- 适用库:changzhou(127.0.0.1:23306,docker 容器 mysql_db;dev/pro 配置一致)
|
||||
-- 对照时间:2026-09-17,库内共 163 张表,本脚本覆盖全部业务表
|
||||
-- ⚠️ 高危操作!执行前务必完整备份数据库!
|
||||
-- ⚠️ TRUNCATE 不可回滚,且会重置自增主键
|
||||
-- ⚠️ 若某张表在你的环境中不存在,请注释掉对应行(TRUNCATE 无 IF EXISTS)
|
||||
-- ============================================================
|
||||
|
||||
-- 关闭外键检查,避免因约束顺序报错
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- ============================================================
|
||||
-- Part 1: 系统日志表
|
||||
-- ============================================================
|
||||
TRUNCATE TABLE sys_logininfor; -- 登录日志
|
||||
TRUNCATE TABLE sys_oper_log; -- 操作日志
|
||||
TRUNCATE TABLE sys_job_log; -- 定时任务日志
|
||||
|
||||
-- 清用户
|
||||
TRUNCATE TABLE tt_user;
|
||||
|
||||
-- ============================================================
|
||||
-- Part 2: 财务流水
|
||||
-- ============================================================
|
||||
TRUNCATE TABLE tt_user_blend_ercash; -- 余额/积分变动主流水
|
||||
TRUNCATE TABLE tt_user_blend_ercash_archive; -- 流水归档表
|
||||
TRUNCATE TABLE tt_user_amount_records; -- 金币流水明细
|
||||
TRUNCATE TABLE tt_user_credits_records; -- 积分流水明细
|
||||
TRUNCATE TABLE tt_user_source_report; -- 用户来源日报表(累计流水统计)
|
||||
TRUNCATE TABLE tt_user_daily_turnover; -- 用户每日流水统计(VIP 返利/排行榜口径)
|
||||
TRUNCATE TABLE tt_recharge_record; -- 充值记录
|
||||
TRUNCATE TABLE tt_recharge_card; -- 充值卡密
|
||||
TRUNCATE TABLE tt_blank_card_key; -- 空白卡密(后台生成)
|
||||
TRUNCATE TABLE promo_card_key; -- 卡商卡密
|
||||
TRUNCATE TABLE promo_cdkey; -- 推广CDKEY创建记录表
|
||||
TRUNCATE TABLE promo_cdkey_redeem; -- 推广CDKEY兑换记录表
|
||||
TRUNCATE TABLE tt_order; -- 订单
|
||||
TRUNCATE TABLE tianxin_order; -- 天心支付订单
|
||||
TRUNCATE TABLE tt_loss_rebate; -- 亏损返水结算明细(回血红包)
|
||||
TRUNCATE TABLE tt_comp_record; -- 补偿值记录(user_id + box_id + comp_amount)
|
||||
TRUNCATE TABLE tt_promotion_update; -- 推广上下级关系变更记录
|
||||
TRUNCATE TABLE tt_fight_ranking_reward; -- 对战排行榜奖励发放记录
|
||||
TRUNCATE TABLE tt_recharge_ranking_reward; -- 充值排行榜奖励发放记录
|
||||
TRUNCATE TABLE tt_user_sub_account; -- 主号-副号关联
|
||||
TRUNCATE TABLE tt_user_avatar; -- 用户头像
|
||||
|
||||
-- ============================================================
|
||||
-- Part 3: 游戏记录
|
||||
-- ============================================================
|
||||
|
||||
-- 开箱
|
||||
TRUNCATE TABLE tt_box_records;
|
||||
TRUNCATE TABLE tt_user_box_open_chance; -- 用户开箱机会
|
||||
TRUNCATE TABLE tt_box_third_explosive_user; -- 第三方爆率用户记录
|
||||
|
||||
-- 对战(tt_fight_round / tt_fight_result 为 DB 标注的废弃表,0 行)
|
||||
TRUNCATE TABLE tt_fight;
|
||||
ALTER TABLE tt_fight AUTO_INCREMENT = 1000001;
|
||||
TRUNCATE TABLE tt_fight_user;
|
||||
TRUNCATE TABLE tt_fight_round;
|
||||
TRUNCATE TABLE tt_fight_result;
|
||||
|
||||
-- Roll房
|
||||
TRUNCATE TABLE tt_roll;
|
||||
TRUNCATE TABLE tt_roll_user;
|
||||
TRUNCATE TABLE tt_roll_user_prize;
|
||||
TRUNCATE TABLE tt_time_roll; -- 定时Roll房主表
|
||||
TRUNCATE TABLE tt_time_roll_user; -- 定时Roll房参与记录
|
||||
|
||||
-- 幸运升级
|
||||
TRUNCATE TABLE tt_upgrade_record;
|
||||
|
||||
-- 指数盲盒(tt_exponent 是指数期号历史,勿清空,见 Part 7)
|
||||
TRUNCATE TABLE tt_exponent_user;
|
||||
TRUNCATE TABLE tt_exponent_user_box;
|
||||
|
||||
-- 极速糖果(sweet)
|
||||
TRUNCATE TABLE game_sweet_record; -- 对局记录
|
||||
TRUNCATE TABLE game_sweet_user; -- 用户累计统计
|
||||
TRUNCATE TABLE game_sweet_win; -- 每日盈亏统计
|
||||
|
||||
-- 疯狂转盘(wheel)
|
||||
TRUNCATE TABLE game_wheel_round; -- 每期开奖
|
||||
TRUNCATE TABLE game_wheel_round_bet; -- 下注明细
|
||||
TRUNCATE TABLE game_wheel_user; -- 用户累计统计
|
||||
TRUNCATE TABLE game_wheel_pool_adjustment; -- 奖池调节记录
|
||||
|
||||
-- 玫瑰女郎(olympus)
|
||||
TRUNCATE TABLE game_olympus_record; -- 玩家游戏记录
|
||||
TRUNCATE TABLE game_olympus_user; -- 用户累计统计
|
||||
TRUNCATE TABLE game_olympus_win; -- 每日盈亏
|
||||
TRUNCATE TABLE game_olympus_reward_log; -- 奖池水位调整日志
|
||||
|
||||
-- ============================================================
|
||||
-- Part 4: 预生成回合池(默认注释)
|
||||
-- ⚠️ 这些不是配置,是预生成回合池:清空后必须在后台重新生成/重算,
|
||||
-- 否则对应玩法开局时取不到回合。数据量大(约 90 万行),
|
||||
-- 要彻底重置磁盘占用再放开本段。
|
||||
-- ============================================================
|
||||
-- TRUNCATE TABLE game_sweet_round; -- 极速糖果预生成回合(约 8.4 万行)
|
||||
-- TRUNCATE TABLE game_sweet_step; -- 极速糖果回合步骤(约 75 万行 / 626MB)
|
||||
-- TRUNCATE TABLE game_olympus_round; -- 玫瑰女郎预生成回合(约 2.9 万行)
|
||||
-- TRUNCATE TABLE game_olympus_step; -- 玫瑰女郎回合步骤(约 12.9 万行)
|
||||
|
||||
-- ============================================================
|
||||
-- Part 5: 辅助数据
|
||||
-- ============================================================
|
||||
TRUNCATE TABLE tt_attendance_record; -- 签到记录
|
||||
TRUNCATE TABLE tt_message; -- 用户消息
|
||||
TRUNCATE TABLE tt_message_send; -- 消息下发记录
|
||||
TRUNCATE TABLE tt_notice; -- 用户通知
|
||||
TRUNCATE TABLE sys_notice; -- 系统公告
|
||||
TRUNCATE TABLE tt_announcement_read; -- 公告已读记录
|
||||
TRUNCATE TABLE tt_bonus_receive_record; -- 奖励包领取记录
|
||||
TRUNCATE TABLE tt_red_packet; -- 红包
|
||||
TRUNCATE TABLE tt_red_packet_record; -- 红包领取记录
|
||||
TRUNCATE TABLE tt_commission_record; -- 佣金收益记录
|
||||
TRUNCATE TABLE tt_promotion_record; -- 推广记录
|
||||
TRUNCATE TABLE tt_task_center_user; -- 任务中心用户进度
|
||||
TRUNCATE TABLE tt_welfare_record; -- 福利领取记录
|
||||
TRUNCATE TABLE tt_welfare_box_inventory; -- 用户福利箱子库存
|
||||
TRUNCATE TABLE tt_turnover_lottery_record; -- 流水抽奖记录
|
||||
|
||||
-- ============================================================
|
||||
-- Part 6: 发货链路
|
||||
-- ============================================================
|
||||
TRUNCATE TABLE tt_delivery_record; -- 发货记录
|
||||
TRUNCATE TABLE tt_box_transfer_record; -- 饰品转移记录
|
||||
TRUNCATE TABLE tt_replacement_record; -- 饰品替换记录
|
||||
TRUNCATE TABLE tt_transfer_apply; -- 转赠申请(按批次聚合审核)
|
||||
TRUNCATE TABLE tt_user_transfer_daily; -- 用户每日转赠次数记录
|
||||
|
||||
-- ============================================================
|
||||
-- Part 7: 明确【不要】清空的表(说明,勿放开)
|
||||
-- ============================================================
|
||||
-- game_wheel_reward_matrix 疯狂转盘 120 条固定奖励矩阵,删行会导致「命中缺行漏发」
|
||||
-- tt_exponent 指数期号历史;TtExponentGenerateTask 的 @Scheduled 已注释停用,
|
||||
-- 清空后当天无指数记录,指数盲盒无法开局
|
||||
-- 宝箱 / 饰品 / 各玩法配置 tt_box*、tt_ornament*、tt_game_mould*、game_sweet_*、
|
||||
-- game_olympus_*、game_wheel_prize_*、tt_roll_prize_template*、
|
||||
-- tt_roll_jackpot*、tt_upgrade_ornaments*、tt_recharge_config/prod、
|
||||
-- tt_vip_level、tt_welfare*、tt_task_center、tt_turnover_lottery*、
|
||||
-- tt_robot_fight_*、tt_content*、tt_banner、tt_advertisement、
|
||||
-- tt_announcement、promo_admin / promo_menu_config / promo_role_menu
|
||||
-- 一律不清:清空后要重新导入配置,本脚本不负责恢复
|
||||
-- qrtz_* 调度器运行状态(任务定义在 sys_job)
|
||||
-- sys_user / sys_role / sys_menu / sys_config / sys_dict_* / gen_table*
|
||||
-- 框架、权限、字典与代码生成器数据,清了后台就进不去
|
||||
-- *_new / *_old 历史重建脚本(ruoyi-ui/main.py)遗留的影子表,确认无引用后直接 DROP,不必纳入本脚本
|
||||
|
||||
-- 恢复外键检查
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- ============================================================
|
||||
-- 维护提示:新增业务表后请同步本文件,可用下面两条反查差异
|
||||
-- 1) 库内全部表:SELECT table_name FROM information_schema.tables
|
||||
-- WHERE table_schema='changzhou' AND table_type='BASE TABLE' ORDER BY table_name;
|
||||
-- 2) 本脚本已覆盖的表:在本文件中搜索 TRUNCATE TABLE
|
||||
-- ============================================================
|
||||
+1748
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,340 @@
|
||||
# 幸运升级接口文档(Cocos 客户端对接)
|
||||
|
||||
> 适用版本:当前 master。所有接口路径前缀 `/api/luckyUpgrade`(另有 1 个关联接口在 `/api/userPackSack`)。
|
||||
> 代码位置:`skins-service/service-playingmethod/.../ApiLuckyUpgradeController.java`、`ApiLuckyUpgradeServiceImpl.java`、`ApiUpgradeRecordServiceImpl.java`。
|
||||
|
||||
---
|
||||
|
||||
## 一、玩法机制(实现口径)
|
||||
|
||||
### 1.1 一局流程
|
||||
|
||||
```
|
||||
选饰品位(upgradeId) + 选成功率(probability)
|
||||
↓ 后端按实时价算钱:price = 饰品价 × probability / 100
|
||||
↓ 校验(身份 / 封禁 / 删除 / 未充值 703 / 余额 702)
|
||||
↓ 加锁(饰品位 + 奖池类型)→ 扣款(source=40 幸运升级消费)
|
||||
↓ 判定(见 1.2)
|
||||
├─ 成功:获得该饰品位对应的整件饰品,进度与幸运值清 0
|
||||
└─ 失败:获得后台配置的「失败奖励」饰品(安慰奖,可多件),进度与幸运值累加
|
||||
↓ 写 tt_upgrade_record 记录(含各项快照)+ 背包 tt_box_records(source=4 幸运升级)+ 站内消息
|
||||
↓ 返回升级记录(含 gainOrnamentList,可用于结果页立即分解)
|
||||
```
|
||||
|
||||
### 1.2 消耗与判定公式
|
||||
|
||||
| 项 | 公式 / 取值 |
|
||||
|---|---|
|
||||
| 单次消耗 | `price = 饰品实时价(tt_ornament.use_price) × probability / 100`,保留 2 位小数(**前端不传价格**,由后端算,防 0 元 100% 中奖) |
|
||||
| 判定概率 | `eff = min(probability, 75)`(**封顶 75**:选 90% 也只按 75% 判定,但钱按 90% 收) |
|
||||
| 本轮空间 | `fullV`:从该饰品位对应奖池的幸运区间 `[min, max)` 里随机取 `nextInt(max-min) + min` |
|
||||
| 判定 | 每拉一次 `进度 += eff`;`进度 + eff >= fullV` 即中奖,中奖后 `fullV` 重新随机、进度清 0;否则失败继续累加 |
|
||||
| 推论 | **玩家为一件饰品付出的总成本 ≈ 饰品价 × fullV / 100**。所以幸运区间 = 该池的"售价百分比区间":`[50,250]` 表示平均要付 50%~250% 原价才出;压到个位数(如 `[9,10]`)等于必中、平台必亏 |
|
||||
|
||||
### 1.3 幸运值与幸运区间:三套独立奖池
|
||||
|
||||
`幸运值` = 该池"累计原始概率",失败累加 `probability`(原始值,不是封顶后的 75)、中奖清 0;界面与记录里展示的就是它。
|
||||
|
||||
| 奖池 | 判定/显示用 userType | 幸运区间字段 | 幸运值字段 | 本轮已投金额字段 |
|
||||
|---|---|---|---|---|
|
||||
| 普通用户 | `02` | `tt_upgrade_ornaments.luck_section` | `probability_invested` | `amount_invested` / `effective_amount_invested` |
|
||||
| 主播 | `01` | `anchor_luck_section` | `anchor_probability_invested` | `anchor_amount_invested` / `anchor_effective_amount_invested` |
|
||||
| 白号 | `06`(`is_blank_account=1` 优先判定) | `blank_luck_section`(未配置回退 `luck_section`) | `blank_probability_invested` | `blank_amount_invested` / `blank_effective_amount_invested` |
|
||||
|
||||
- 三池**完全隔离**:进度缓存在 Redis `upgrade_range_fixed:{饰品位}:{池类型}` / `upgrade_range:{饰品位}:{池类型}`,互不影响、互不阻塞(锁也是按「饰品位 + 池类型」)。
|
||||
- 同一池内是**共享进度**:同一个池子的玩家一起把进度推到 `fullV`,谁那一拉跨过阈值谁中奖(和普通/主播现有行为一致,白号同理,白号之间共享 06 池)。
|
||||
- 白号记录里的幸运值/已投金额读的都是白号自己的池,不再是真实玩家池的读数。
|
||||
|
||||
### 1.4 获得与失败奖励
|
||||
|
||||
- **成功**:获得 `tt_upgrade_ornaments.ornaments_id` 对应的整件饰品,`gain_ornaments_price` = 该饰品实时价。
|
||||
- **失败**:获得后台「幸运升级 → 失败奖励」为该饰品位配置的饰品(`tt_upgrade_fail_ornaments`,可多件,按 `ornament_number` 计数),`gain_ornaments_price` = 安慰奖总价值;该饰品位**没配失败奖励时啥都不给**(`gainOrnamentList` 为 `[]`、金额 0)。
|
||||
- 所有获得物都写入背包 `tt_box_records`(`source = 4` 幸运升级、状态 = 在背包)。
|
||||
|
||||
### 1.5 流水与返佣口径
|
||||
|
||||
| 场景 | 流水 source |
|
||||
|---|---|
|
||||
| 升级消费 | `40`(GOLD_DEVICE,「幸运升级消费」,输出) |
|
||||
| 游戏内分解(结果页立即分解) | `41`(GOLD_DEVICE_REWARD,「幸运升级奖励」,输入) |
|
||||
| 背包里分解(事后在背包页分解) | `10`(DECOMPOSE_ORNAMENT,输入) |
|
||||
|
||||
- 消费 source 40 计入:今日/历史投注流水、综合消费排行榜、流水抽奖资格、**上级返佣**(按 `classicGameCommissionRate` 口径)、**VIP 流水返利**。
|
||||
- **白号例外**:白号消费不触发上级返佣、不触发 VIP 流水返利;后台充值/流水/记录类页面普遍按 `is_blank_account=1` 排除白号。
|
||||
|
||||
---
|
||||
|
||||
## 二、通用约定
|
||||
|
||||
- **域名**:`{host}`(例:`http://103.108.67.60:28081`)。
|
||||
- **Content-Type**:`application/json`;**鉴权**:除 `getOrnamentsList` 外都需要请求头带 token,401 时按项目统一逻辑弹登录。
|
||||
- **统一响应**:
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "操作成功", "data": { } }
|
||||
```
|
||||
|
||||
| code | 含义 |
|
||||
|---|---|
|
||||
| 200 | 成功(客户端以 `code === 200` 判定) |
|
||||
| 401 | 未登录 / 登录过期,msg「登录过期,请重新登录。」 |
|
||||
| 702 | 账户余额不足 |
|
||||
| 703 | 玩家未充值不能游玩任何游戏 |
|
||||
| 500 | 业务失败,msg 直接给玩家看 |
|
||||
|
||||
- **维护开关**(`sys_config`,后台「网站设置」):`websiteMaintenance=1` → 「网站维护中......」;`luckyUpgradeMaintenance=1` → 「幸运升级维护中......」。两个开关都**先于登录校验**返回。
|
||||
- **通用拦截**(`upgrade` 接口):非 主播(01)/普通(02)/白号 → 「非法的用户类型」;`status=1` → 「账号已被封禁,无法参与游戏」;`del_flag != 0` → 「账号状态异常,无法参与游戏」。
|
||||
- 幸运升级相关也有 `/api/luckyUpgrade/**` 其他接口,本文件只覆盖下表这些。
|
||||
|
||||
---
|
||||
|
||||
## 三、接口清单
|
||||
|
||||
| 接口 | 说明 | 鉴权 | 分页 |
|
||||
|---|---|---|---|
|
||||
| `POST /api/luckyUpgrade/getOrnamentsList` | 可升级饰品列表(含当前幸运值) | 否(未登录按普通用户口径) | 无 |
|
||||
| `POST /api/luckyUpgrade/upgrade` | 拉取一次(扣款 + 判定 + 发奖) | 是 | - |
|
||||
| `POST /api/luckyUpgrade/recordPage` | 记录页:全服/我的 × 当前饰品/全部饰品 | 是 | 真分页 |
|
||||
| `POST /api/luckyUpgrade/recentPulls` | 某饰品位最近 50 次拉取流水 | 是 | 无(固定 50) |
|
||||
| `POST /api/luckyUpgrade/myHistoryRecord` | 我的记录(全部饰品,成败都返回) | 是 | 真分页 |
|
||||
| `POST /api/luckyUpgrade/getUpgradeRecord` | 通用条件查询(内部/后台复用) | 是 | 真分页 |
|
||||
| `POST /api/luckyUpgrade/tenTopUpgradeRecord` | 十佳掉落榜(按实际获得价值 Top10) | 是 | 无(固定 10) |
|
||||
| `POST /api/userPackSack/decompose` | 分解获得的饰品(结果页或背包) | 是 | - |
|
||||
|
||||
---
|
||||
|
||||
## 四、接口详情
|
||||
|
||||
### 4.1 `POST /api/luckyUpgrade/getOrnamentsList` — 可升级饰品列表
|
||||
|
||||
**请求**(全部可选,无分页、无排序参数,返回按 `use_price` 升序)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | Integer | 可直接取某一个饰品位(`tt_upgrade_ornaments.id`) |
|
||||
| type | String | 饰品类型数字字典码(1匕首 2手枪 3步枪 4微型冲锋枪 5重型武器 6手套 7印花 8其它);空串按不传处理 |
|
||||
| itemName | String | 饰品名称模糊匹配 |
|
||||
| priceMin / priceMax | BigDecimal | 按价格区间过滤(**只在传了 `priceMax` 时生效**;不传 `priceMin` 默认 0) |
|
||||
| userType | String | **后端注入,客户端不要传**(用登录用户身份决定返回哪个池的幸运值) |
|
||||
|
||||
```json
|
||||
{ "type": "3", "itemName": "", "priceMax": 5000 }
|
||||
```
|
||||
|
||||
**响应**:`data` 为数组,仅返回 `status='0'`(启用)的饰品位:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | Integer | **可升级饰品ID**(后续 `upgrade.upgradeId`、`recordPage.upgradeId`、`recentPulls.upgradeRecordId` 都传它) |
|
||||
| ornamentId | Long | 饰品ID(`tt_ornament.id`) |
|
||||
| name | String | 饰品全名(优先展示) |
|
||||
| itemName | String | 名称(与 name 同源字段) |
|
||||
| usePrice | BigDecimal | 实时价格(= 计算消耗、展示目标金额的基准) |
|
||||
| imageUrl | String | 饰品图片 |
|
||||
| shortName / typeName / rarityName / exteriorName / levelImg | String | 简称 / 类型 / 稀有度 / 磨损度 / 品质图 |
|
||||
| luckyValue | Integer | **当前幸运值**(按登录身份取对应池:普通/主播/白号) |
|
||||
|
||||
### 4.2 `POST /api/luckyUpgrade/upgrade` — 拉取一次
|
||||
|
||||
**请求**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| upgradeId | Integer | 是 | 可升级饰品ID(`getOrnamentsList` 的 `id`) |
|
||||
| probability | Integer | 是 | 本次选择的成功率,1~100;**判定按 min(概率,75),扣款按传入值** |
|
||||
|
||||
```json
|
||||
{ "upgradeId": 25, "probability": 10 }
|
||||
```
|
||||
|
||||
**响应**:`data` = 本条升级记录(`tt_upgrade_record`),字段如下:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | Long | 升级记录ID |
|
||||
| userId / nickName / userType / userAvatar | - | 玩家信息(昵称是升级时快照) |
|
||||
| probability | Integer | 本次选择的成功率(原始概率快照) |
|
||||
| luckyValue | Integer | 本次升级时的幸运值快照(中奖为 0) |
|
||||
| amountConsumed | BigDecimal | 本次实际消耗 |
|
||||
| amountRequired | BigDecimal | 本轮目标金额快照 = 饰品价 × fullV% |
|
||||
| amountInvested / effectiveAmountInvested | BigDecimal | 本轮已投金额(实际扣款口径 / 真实计算口径)快照 |
|
||||
| effectiveAmount | BigDecimal | 真实计算金额 = 饰品价 × min(概率,75)% |
|
||||
| targetUpgradeId | Integer | 可升级饰品ID |
|
||||
| targetOrnamentId | Long | 目标饰品ID |
|
||||
| targetOrnamentPrice | BigDecimal | 目标饰品价格 |
|
||||
| isVictory | Boolean | 成败:true=升级成功 |
|
||||
| gainOrnamentsPrice | BigDecimal | 本次获得饰品总价值(成功=目标饰品价,失败=安慰奖总价,可能为 0) |
|
||||
| gainOrnamentList | Array | **本次实际获得物**(JSON 数组,不是字符串),每项:`id`=背包记录ID(`tt_box_records.id`)、`ornamentId`、`ornamentName`、`ornamentHashName`、`ornamentPrice`、`ornamentImgUrl`、`ornamentLevelId`、`ornamentLevelImg`、`ornamentNumber`;无获得时为 `[]` |
|
||||
| openTime | String | 升级时间 `yyyy-MM-dd HH:mm:ss` |
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"id": 93, "userId": 34052018, "probability": 10, "luckyValue": 0,
|
||||
"amountConsumed": 181.95, "amountRequired": 1819.52, "isVictory": true,
|
||||
"gainOrnamentsPrice": 1819.52,
|
||||
"gainOrnamentList": [
|
||||
{ "id": 11530, "ornamentId": 1312995578, "ornamentName": "AK-47 | 表面淬火 (略有磨损)",
|
||||
"ornamentPrice": 1819.52, "ornamentImgUrl": "/uploads/xxx.png", "ornamentLevelImg": "/profile/xxx.png" }
|
||||
],
|
||||
"openTime": "2026-09-11 18:24:01"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**失败返回示例**:`{"code":500,"msg":"系统繁忙,请稍后重试。"}`(同池并发抢锁失败,客户端可直接重试);`{"code":703,"msg":"玩家未充值不能游玩任何游戏"}`;`{"code":702,"msg":"账户余额不足"}`;`{"code":500,"msg":"该饰品未在幸运升级开放。"}`。
|
||||
|
||||
**结果页立即分解**:取 `gainOrnamentList[i].id` 作为 `packSackIds`,调 4.8 接口(`decomposeType="game"`)。不分解就等于饰品留在背包里。
|
||||
|
||||
### 4.3 `POST /api/luckyUpgrade/recordPage` — 记录页(主力接口)
|
||||
|
||||
**请求**(二维组合,共 4 种场景,均需登录)
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| scope | Integer | 否 | 0 | 0=全服(不过滤用户) 1=我的 |
|
||||
| itemScope | Integer | 否 | 0 | 0=全部饰品 1=当前饰品 |
|
||||
| upgradeId | Long | 条件必填 | - | 可升级饰品ID;`itemScope=1` 时必填,为空返回 500;`itemScope=0` 时传了也忽略 |
|
||||
| page | Integer | 否 | 1 | 页码(≥1) |
|
||||
| size | Integer | 否 | 10 | 每页条数(1~20,超限参数错误) |
|
||||
|
||||
```json
|
||||
{ "scope": 1, "itemScope": 1, "upgradeId": 25, "page": 1, "size": 10 }
|
||||
```
|
||||
|
||||
**响应**:`data` 为分页对象 `{ records, total, current, size, pages }`,`records` 按 `openTime` 倒序:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| userId / avatar / nickName / vipLevel | - | 玩家信息(昵称是升级时快照) |
|
||||
| probability | Integer | 成功率(原始概率,如 89 表示 89%) |
|
||||
| luckyValue | Integer | 幸运值快照(按该玩家所属池;白号是白号池的值) |
|
||||
| amountConsumed | BigDecimal | 消耗 |
|
||||
| gainOrnamentsPrice | BigDecimal | 获得饰品总价值 |
|
||||
| isVictory | Boolean | 成败:true=升级成功(前端胜负样式用) |
|
||||
| openTime | String | 升级时间 |
|
||||
| targetOrnamentId | Long | 目标饰品ID |
|
||||
| name / imageUrl / usePrice / exteriorName / levelImg | - | **本次实际获得的饰品**:成功=目标饰品,失败=安慰奖饰品(`gainOrnamentList` 首件);未配安慰奖的失败记录会回退成目标饰品且金额为 0,**胜负请用 isVictory 判断,不要看图片** |
|
||||
|
||||
- 全服(scope=0)不过滤白号、机器人、封禁用户,白号可正常上榜。
|
||||
- 旧的 `recentPulls` / `myHistoryRecord` 场景都被本接口覆盖,可继续用但建议逐步切到本接口。
|
||||
|
||||
### 4.4 `POST /api/luckyUpgrade/recentPulls` — 某饰品位最近 50 次拉取流水
|
||||
|
||||
**请求**:`{ "upgradeRecordId": 25 }`
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| upgradeRecordId | Long | 是 | ⚠️ 字段名有历史误导性:**实际语义是「可升级饰品ID」`tt_upgrade_ornaments.id`**(`getOrnamentsList` 的 `id`)。为空返回 500「upgradeRecordId(升级饰品ID)不能为空」 |
|
||||
| 其他字段 | - | 否 | `ornamentId`/`userType`/`userId`/`page`/`size`/`limit` 全部忽略 |
|
||||
|
||||
- **无分页**:固定最近 **50** 条(SQL `LIMIT 50`),没有 `page`/`total`。
|
||||
- **不过滤**:全服所有人(含白号、机器人、封禁/删除用户),成功与失败都返回。
|
||||
- `nickName`/`avatar`/`vipLevel` 可能为 `null`(该记录的用户已不在 `tt_user`),客户端要能容错。
|
||||
|
||||
**响应**:`data` 直接是数组(不是分页对象):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| avatar / nickName / vipLevel | - | 拉取人信息 |
|
||||
| probability | Integer | 本次拉取**选择的成功率**(不是中奖率) |
|
||||
| luckyValue | Integer | 本次拉取时的**幸运值快照**(累计原始概率;成功为 0),与 `recordPage` 同口径 |
|
||||
| isVictory | Boolean | 成败:true=升级成功 |
|
||||
| name / imageUrl / usePrice / exteriorName / levelImg | - | **目标饰品**("谁在拉这个饰品",不是获得物) |
|
||||
| openTime | String | 拉取时间 |
|
||||
|
||||
### 4.5 `POST /api/luckyUpgrade/myHistoryRecord` — 我的记录(全部饰品)
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| page | Integer | 否 | 1 | 页码 |
|
||||
| size | Integer | 否 | 10 | 1~20 |
|
||||
|
||||
**响应**:`data` 为分页对象,`records` 是 `tt_upgrade_record`(字段同 4.2 的响应,含 `gainOrnamentList`),仅当前登录用户、不含他人记录、成败都返回、按时间倒序;昵称/头像会补齐。
|
||||
|
||||
### 4.6 `POST /api/luckyUpgrade/getUpgradeRecord` — 通用条件查询
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| page / size | Integer | **必传** | 不传会 500(内部直接用于构造分页对象) |
|
||||
| upgradeRecordId | Long | 否 | 按记录ID精确查 |
|
||||
| ornamentId | Long | 否 | 按目标饰品ID过滤 |
|
||||
| userType | String | 否 | 按用户类型过滤(01/02/06…) |
|
||||
| userId | Integer | 否 | 按用户过滤 |
|
||||
|
||||
**响应**:`data` 为分页对象(`records` 为 `tt_upgrade_record`,`userAvatar` 已补齐,`gainOrnamentList` 空值兜底为 `[]`)。主要用于内部/后台复用,客户端记录页请用 `recordPage`。
|
||||
|
||||
### 4.7 `POST /api/luckyUpgrade/tenTopUpgradeRecord` — 十佳掉落榜
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| isVictory | Boolean | 否 | `true` 只看成功、`false` 只看失败;不传则全部记录一起排 |
|
||||
|
||||
**响应**:`data` 为分页对象(固定第 1 页、10 条),按 **`gainOrnamentsPrice`(实际获得价值)倒序**取 Top10 —— 即"实际掉落榜",不是目标饰品价值榜。
|
||||
|
||||
### 4.8 `POST /api/userPackSack/decompose` — 分解饰品(结果页/背包)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| packSackIds | Long[] | 条件必填 | 背包记录ID(`tt_box_records.id`,即 `gainOrnamentList[i].id`);`isAll=true` 时可不传 |
|
||||
| isAll | Boolean | 是 | 是否全选 |
|
||||
| decomposeType | String | 否 | `game`=游戏结果页立即分解(按饰品来源记 `GOLD_DEVICE_REWARD(41)`);`backpack`=背包分解(记 `DECOMPOSE_ORNAMENT(10)`) |
|
||||
|
||||
返回 `{"code":200,"msg":"成功分解N件饰品"}`。受 `websiteMaintenance` 开关拦截。
|
||||
|
||||
---
|
||||
|
||||
## 五、前端对接要点
|
||||
|
||||
1. **upgradeId 语义**:所有接口里的 `upgradeId` / `recentPulls.upgradeRecordId` 都是**可升级饰品ID**(`tt_upgrade_ornaments.id`),来自 `getOrnamentsList` 的 `id`,不是 `tt_ornament.id`,也不是升级记录ID。
|
||||
2. **扣款以服务端为准**:不要传价格;展示价格用 `usePrice × probability / 100`(与后端同公式,后端保留 2 位小数)。
|
||||
3. **概率封顶 75**:UI 上若提供 >75% 的档位,要提示"更高概率只加钱、不提升判定"。
|
||||
4. **幸运值怎么用**:主界面实时幸运值取 `getOrnamentsList.luckyValue`;拉取成功后用 `upgrade` 响应里的 `luckyValue` 立即覆盖,不必等列表刷新。记录里的幸运值是历史快照。
|
||||
5. **分池**:幸运值/幸运区间按 普通(02) / 主播(01) / 白号(06) 三套独立,白号看到的是白号池的值;同一池内进度是共享的,别人垫的进度也算在你头上。
|
||||
6. **分页**:`recordPage`/`myHistoryRecord` 的 `size` 上限 20;切换 tab(我的/全服、当前饰品/全部饰品)时 `page` 重置为 1。
|
||||
7. **失败记录展示**:以 `isVictory` 判断胜负,`name/imageUrl/...` 是"实际获得物"(失败=安慰奖);无安慰奖时金额为 0。
|
||||
8. **结果页分解**:`gainOrnamentList` 里的 `id` 就是分解入参;不调用 = 饰品留在背包。
|
||||
9. **并发**:同一饰品位 + 同一奖池同时拉取会串行,抢不到锁返回 500「系统繁忙,请稍后重试。」,可直接提示重试。
|
||||
|
||||
---
|
||||
|
||||
## 六、后台配置与调参
|
||||
|
||||
### 6.1 「幸运升级 → 饰品列表」(`tt_upgrade_ornaments`)
|
||||
|
||||
| 后台列 | 字段 | 说明 |
|
||||
|---|---|---|
|
||||
| 普通用户 / 幸运区间 | `luck_section` | 普通用户池的 `[min, max)`,即"售价百分比区间" |
|
||||
| 普通用户 / 本轮已投金额 | `amount_invested` | 普通池当前累计投入(运行时值,后台不可编辑回写) |
|
||||
| 主播用户 / 幸运区间 | `anchor_luck_section` | 主播池区间 |
|
||||
| 白号用户 / 幸运区间 | `blank_luck_section` | 白号池区间;为空则回退 `luck_section` |
|
||||
| 白号用户 / 本轮已投金额 | `blank_amount_invested` | 白号池当前累计投入 |
|
||||
| 状态 | `status` | 0=启用(=可升级、会出现在客户端列表) 1=禁用 |
|
||||
|
||||
- 调难度就调"幸运区间":区间 = 玩家为该饰品付出的**价格百分比**。想更容易出就整体下调(如 `[80,280]`→`[60,200]`);**不要压到个位数**(如 `[9,10]` 等于必中,等于按 9% 价格卖货),也不要配成 `min == max`(会被后端防护强制改成 `[0,160]`)。
|
||||
- **改完区间要清缓存才立即生效**:`DEL upgrade_range:{饰品位}:{池}` + `DEL upgrade_range_fixed:{饰品位}:{池}`(池=01/02/06)。不清的话当前这一轮仍按旧空间跑,等本轮中奖后自动按新配置重建。Redis 实测在 db0,密码见 `application-*.yml`。
|
||||
- 白号是独立奖池,可与普通/主播配不同的区间;「批量填货」新增饰品位时白号区间默认取普通用户的默认值。
|
||||
|
||||
### 6.2 「幸运升级 → 失败奖励」(`tt_upgrade_fail_ornaments`)
|
||||
|
||||
每个饰品位可配多件失败奖励(`ornament_name` / `ornament_price` / `ornament_number` / `ornament_level_id`),玩家升级失败即发放这些饰品;**没配就不发**(记录里 `gainOrnamentList=[]`、`gainOrnamentsPrice=0`)。
|
||||
|
||||
### 6.3 维护开关(后台「网站设置」,`sys_config`)
|
||||
|
||||
| key | 值 | 效果 |
|
||||
|---|---|---|
|
||||
| `websiteMaintenance` | `1` | 全站维护:`upgrade` 与分解接口都拦截 |
|
||||
| `luckyUpgradeMaintenance` | `1` | 只拦幸运升级:`upgrade` 返回「幸运升级维护中......」 |
|
||||
|
||||
### 6.4 数据落库
|
||||
|
||||
| 表 | 说明 |
|
||||
|---|---|
|
||||
| `tt_upgrade_record` | 每条拉取记录(成败、消耗、获得总额、各项快照、`gain_ornament_list` JSON、`lucky_value`) |
|
||||
| `tt_box_records` | 获得物入背包(`source=4` 幸运升级),`id` 即结果页分解入参 |
|
||||
| `tt_user_blend_ercash` | 余额流水:消费 40、游戏内分解 41、背包分解 10 |
|
||||
| `tt_upgrade_ornaments` | 饰品位配置 + 三个池的运行进度(各池幸运值/已投金额) |
|
||||
|
||||
字段变更历史见 `docs/updata_sql.md`(含白号三列的 ALTER)。
|
||||
@@ -0,0 +1,447 @@
|
||||
# 流水抽奖接口文档(Cocos 客户端对接)
|
||||
|
||||
## 一、功能说明
|
||||
|
||||
玩家**无需额外付费**,仅凭当日已发生的游戏下注流水自动累计抽奖资格。
|
||||
|
||||
核心规则:
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 流水来源 | 仅统计游戏消费流水,**排除** Roll 房、商城消费、福利类 |
|
||||
| 兑换比例 | 每个宝箱可单独配置流水门槛(默认 3000 流水 = 1 次) |
|
||||
| 过期机制 | 按自然日统计,次日零点自动清零,不累积 |
|
||||
| 抽奖消耗 | **只消耗"今日流水额度",不扣余额、不扣积分** |
|
||||
| 中奖发放 | 饰品直接进背包(`tt_box_records.source = 14`) |
|
||||
| 余额变动 | 只有玩家在背包**分解**该饰品时才产生余额/积分变动(`source = 63` 流水抽奖奖励) |
|
||||
| 同时开启 | 全站同一时间只有一个抽奖活动为开启状态 |
|
||||
|
||||
参与统计的流水来源码(`TtAccountRecordSource.getTurnoverLotteryCodes()`):
|
||||
|
||||
| source | 含义 |
|
||||
|--------|------|
|
||||
| 2 | 经典开箱消费 |
|
||||
| 4 | 对战消费 |
|
||||
| 40 | 幸运升级消费 |
|
||||
| 38 | 疯狂转盘消费 |
|
||||
| 60 | 奥林匹斯消费 |
|
||||
|
||||
---
|
||||
|
||||
## 二、通用约定
|
||||
|
||||
- **Base URL**:`{host}/api/turnoverLottery`
|
||||
- **请求方式**:全部为 `POST`
|
||||
- **Content-Type**:`application/json`
|
||||
- **鉴权**:需登录态,请求头携带 token。`userId` 由后端从 token 解析,**客户端不要传 userId**
|
||||
|
||||
### 统一响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
| code | 含义 |
|
||||
|------|------|
|
||||
| 200 | 成功 |
|
||||
| 500 | 业务失败,提示语见 `msg` |
|
||||
| 701 | 未实名认证,客户端统一弹实名认证窗口 |
|
||||
|
||||
> 客户端判定成功请以 `code === 200` 为准;失败时直接向玩家展示 `msg`。
|
||||
|
||||
---
|
||||
|
||||
## 三、接口清单
|
||||
|
||||
| # | 接口 | 路径 | 用途 |
|
||||
|---|------|------|------|
|
||||
| 1 | 获取抽奖信息 | `POST /api/turnoverLottery/myStats` | 进入抽奖页拉取流水、次数、宝箱与奖品 |
|
||||
| 2 | 执行抽奖 | `POST /api/turnoverLottery/draw` | 点击抽奖,返回中奖结果供播放动画 |
|
||||
| 3 | 我的抽奖记录 | `POST /api/turnoverLottery/myRecords` | 展示玩家历史中奖记录 |
|
||||
| 4 | 全服中奖记录 | `POST /api/turnoverLottery/globalRecords` | 展示全服玩家中奖公示(跑马灯/滚动列表) |
|
||||
|
||||
---
|
||||
|
||||
## 四、接口详情
|
||||
|
||||
### 1. 获取抽奖信息
|
||||
|
||||
**`POST /api/turnoverLottery/myStats`**
|
||||
|
||||
进入抽奖页时调用,一次性返回玩家流水统计 + 所有开启中的抽奖宝箱及其奖品。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
无需参数,body 可不传(或传空对象 `{}`)。
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"totalTurnover": 12000.00,
|
||||
"usedTurnover": 3000.00,
|
||||
"availableTurnover": 9000.00,
|
||||
"lotteryList": [
|
||||
{
|
||||
"id": 1,
|
||||
"lotteryName": "每日流水抽奖",
|
||||
"status": 1,
|
||||
"turnoverThreshold": 3000,
|
||||
"sort": 0,
|
||||
"lotteryChances": 3,
|
||||
"totalValue": 8250.00,
|
||||
"prizes": [
|
||||
{
|
||||
"id": 6,
|
||||
"ornamentName": "M9 刺刀 | 渐变之色",
|
||||
"price": 8200.00,
|
||||
"imageUrl": "/profile/upload/2026/08/08/xxx.png",
|
||||
"ornamentLevel": 3,
|
||||
"exteriorName": "略有磨损",
|
||||
"levelImg": "/profile/upload/level/red.png",
|
||||
"displayCount": 10,
|
||||
"displayPercent": "50.00%",
|
||||
"realCount": 5,
|
||||
"realPercent": "45.45%",
|
||||
"sort": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 字段说明
|
||||
|
||||
**顶层字段**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `totalTurnover` | Decimal | 今日累计有效流水总额 |
|
||||
| `usedTurnover` | Decimal | 今日已消耗的流水(抽奖已用掉的部分) |
|
||||
| `availableTurnover` | Decimal | **当前可用流水** = `totalTurnover - usedTurnover`,不会为负 |
|
||||
| `lotteryList` | Array | 开启中的抽奖宝箱列表,按 `sort` 升序 |
|
||||
|
||||
**`lotteryList[]` 宝箱字段**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | Long | 宝箱 ID,抽奖时作为 `lotteryId` 传入 |
|
||||
| `lotteryName` | String | 宝箱名称 |
|
||||
| `status` | Int | 状态,始终为 1(接口只返回开启中的) |
|
||||
| `turnoverThreshold` | Int | 该宝箱的流水门槛(多少流水抽 1 次) |
|
||||
| `sort` | Int | 展示排序,越小越靠前 |
|
||||
| `lotteryChances` | Int | **该宝箱可抽次数** = `availableTurnover ÷ turnoverThreshold` 向下取整 |
|
||||
| `totalValue` | Decimal | 奖池总价值(按初始真实数量 × 单价累加) |
|
||||
| `prizes` | Array | 奖品列表,按 `sort` 升序 |
|
||||
|
||||
**`prizes[]` 奖品字段**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | Long | 奖品 ID |
|
||||
| `ornamentName` | String | 饰品名称 |
|
||||
| `price` | Decimal | 饰品价值 |
|
||||
| `imageUrl` | String | 饰品图片 |
|
||||
| `ornamentLevel` | Int | 品质等级 ID |
|
||||
| `exteriorName` | String | 磨损度(崭新出厂/略有磨损/久经沙场/破损不堪/战痕累累/无涂装),可能为 `null` |
|
||||
| `levelImg` | String | 品质底图,可用于饰品卡片背景 |
|
||||
| `displayCount` | Int | 展示数量 |
|
||||
| `displayPercent` | String | **展示爆率**,如 `"50.00%"`,用于前端展示给玩家 |
|
||||
| `realCount` | Int | 当前剩余库存 |
|
||||
| `realPercent` | String | 真实爆率(内部数据,**不建议展示给玩家**) |
|
||||
| `sort` | Int | 展示排序 |
|
||||
|
||||
> **图片路径处理**:`imageUrl` / `levelImg` 若以 `/` 开头(且非 `//`),是服务器相对路径,需拼接资源域名前缀;否则为完整 URL 直接使用。
|
||||
|
||||
> **展示建议**:转盘/宝箱格子的爆率请使用 `displayPercent`,`realPercent` 是后台真实概率,不要展示。
|
||||
|
||||
---
|
||||
|
||||
### 2. 执行抽奖
|
||||
|
||||
**`POST /api/turnoverLottery/draw`**
|
||||
|
||||
玩家点击抽奖时调用。**建议点击立即请求接口,拿到结果后再播放动画**——饰品在接口返回成功时已经入背包,动画只是表现层,即使动画中途活动被关闭也不影响已发的奖。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `lotteryId` | Long | 是 | 宝箱 ID,取 `myStats` 返回的 `lotteryList[].id` |
|
||||
|
||||
```json
|
||||
{
|
||||
"lotteryId": 1
|
||||
}
|
||||
```
|
||||
|
||||
#### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"prizeId": 6,
|
||||
"ornamentName": "M9 刺刀 | 渐变之色",
|
||||
"price": 8200.00,
|
||||
"imageUrl": "/profile/upload/2026/08/08/xxx.png",
|
||||
"ornamentLevel": 3,
|
||||
"exteriorName": "略有磨损",
|
||||
"lotteryName": "每日流水抽奖",
|
||||
"costTurnover": 3000,
|
||||
"msg": "抽奖成功!获得: M9 刺刀 | 渐变之色"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `prizeId` | Long | 中奖奖品 ID,可与 `myStats` 的 `prizes[].id` 对应,用于定位动画停留格子 |
|
||||
| `ornamentName` | String | 中奖饰品名称 |
|
||||
| `price` | Decimal | 饰品价值 |
|
||||
| `imageUrl` | String | 饰品图片 |
|
||||
| `ornamentLevel` | Int | 品质等级 ID |
|
||||
| `exteriorName` | String | 磨损度,可能为 `null` |
|
||||
| `lotteryName` | String | 宝箱名称 |
|
||||
| `costTurnover` | Int | 本次消耗的流水值 |
|
||||
| `msg` | String | 中奖文案,可直接展示 |
|
||||
|
||||
#### 失败响应(`code = 500`,提示语在外层 `msg`)
|
||||
|
||||
| msg | 触发场景 | 客户端处理建议 |
|
||||
|-----|----------|----------------|
|
||||
| `抽奖活动不存在或未开启` | 活动被后台关闭或已删除 | 提示后刷新 `myStats` |
|
||||
| `抽奖次数不足` | 可用流水不够一次门槛 | 提示玩家去游戏累计流水 |
|
||||
| `奖品配置为空` | 该宝箱未配置奖品 | 提示"活动准备中" |
|
||||
| `奖品库存不足` | 全部奖品初始数量为 0 | 提示"活动准备中" |
|
||||
| `抽奖流水门槛配置错误,请联系管理员` | 门槛配置为 0 或负数 | 提示联系客服 |
|
||||
| `抽奖处理中,请勿重复操作` | 同一用户并发点击被锁拦截 | **禁用按钮防连点**,稍后可重试 |
|
||||
| `抽奖处理中,请稍后再试` | 同一宝箱有其他玩家正在抽 | 短暂延迟后自动重试一次 |
|
||||
| `系统繁忙,请稍后重试` | 服务端异常(事务已回滚,未扣流水未发奖) | 提示重试 |
|
||||
|
||||
> **重要**:抽奖接口已加**双层 Redis 分布式锁**(用户级 + 宝箱级),客户端仍需在按钮上做防连点,避免频繁触发"抽奖处理中"。
|
||||
|
||||
#### 抽奖成功后的处理
|
||||
|
||||
1. 用 `prizeId` 定位动画停留位置并播放中奖动画
|
||||
2. 动画结束后重新调用 `myStats` 刷新可用流水与剩余次数
|
||||
3. 饰品已在背包,如需"立即分解"走通用背包分解接口 `/api/userPackSack/decompose`,分解产生的余额/积分变动来源为 `63 流水抽奖奖励`
|
||||
|
||||
---
|
||||
|
||||
### 3. 我的抽奖记录
|
||||
|
||||
**`POST /api/turnoverLottery/myRecords`**
|
||||
|
||||
分页查询当前登录玩家的历史中奖记录,按抽奖时间倒序。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `lotteryId` | Long | 否 | 全部 | 按宝箱筛选,不传查全部 |
|
||||
| `pageNum` | Int | 否 | 1 | 页码,从 1 开始 |
|
||||
| `pageSize` | Int | 否 | 10 | 每页条数,**最大 100** |
|
||||
|
||||
```json
|
||||
{
|
||||
"pageNum": 1,
|
||||
"pageSize": 20
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"total": 35,
|
||||
"pageNum": 1,
|
||||
"pageSize": 20,
|
||||
"rows": [
|
||||
{
|
||||
"id": 128,
|
||||
"userId": 688660,
|
||||
"lotteryId": 1,
|
||||
"prizeId": 6,
|
||||
"ornamentId": null,
|
||||
"ornamentName": "M9 刺刀 | 渐变之色",
|
||||
"imageUrl": "/profile/upload/2026/08/08/xxx.png",
|
||||
"ornamentsPrice": 8200.00,
|
||||
"costTurnover": 3000,
|
||||
"createTime": "2026-08-08 17:18:37"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `total` | Long | 总记录数,用于分页组件 |
|
||||
| `pageNum` | Int | 当前页码 |
|
||||
| `pageSize` | Int | 每页条数 |
|
||||
| `rows` | Array | 记录列表 |
|
||||
| `rows[].id` | Long | 记录 ID |
|
||||
| `rows[].lotteryId` | Long | 宝箱 ID |
|
||||
| `rows[].prizeId` | Long | 奖品 ID |
|
||||
| `rows[].ornamentName` | String | 饰品名称 |
|
||||
| `rows[].imageUrl` | String | 饰品图片 |
|
||||
| `rows[].ornamentsPrice` | Decimal | 奖品价值 |
|
||||
| `rows[].costTurnover` | Int | 该次消耗的流水值 |
|
||||
| `rows[].createTime` | String | 抽奖时间 `yyyy-MM-dd HH:mm:ss` |
|
||||
|
||||
失败时返回 `code = 500`,`msg` 为 `查询失败,请稍后重试`。
|
||||
|
||||
---
|
||||
|
||||
### 4. 全服中奖记录
|
||||
|
||||
**`POST /api/turnoverLottery/globalRecords`**
|
||||
|
||||
分页查询**全服所有玩家**的中奖记录,按中奖时间倒序。用于抽奖页的中奖公示区(跑马灯、滚动榜单)。
|
||||
|
||||
> **已在服务端过滤**:机器人(`user_type='03'`)、白号(`is_blank_account=1`)、封禁用户(`status='1'`)、已删除用户(`del_flag!='0'`)的记录不会出现在结果里,客户端无需再做过滤。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `lotteryId` | Long | 否 | 全部 | 按宝箱筛选,不传查全部宝箱 |
|
||||
| `minPrice` | Decimal | 否 | 不限 | 最低奖品价值门槛,只展示价值 ≥ 该值的记录(做"大奖公示"时用) |
|
||||
| `pageNum` | Int | 否 | 1 | 页码,从 1 开始 |
|
||||
| `pageSize` | Int | 否 | 20 | 每页条数,**最大 100** |
|
||||
|
||||
```json
|
||||
{
|
||||
"minPrice": 1000,
|
||||
"pageNum": 1,
|
||||
"pageSize": 20
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"total": 1286,
|
||||
"pageNum": 1,
|
||||
"pageSize": 20,
|
||||
"rows": [
|
||||
{
|
||||
"id": 128,
|
||||
"userId": 688660,
|
||||
"nickName": "云深不知",
|
||||
"avatar": "/profile/avatar/2026/08/08/aaa.png",
|
||||
"lotteryId": 1,
|
||||
"lotteryName": "每日流水抽奖",
|
||||
"prizeId": 6,
|
||||
"ornamentName": "M9 刺刀 | 渐变之色",
|
||||
"imageUrl": "/profile/upload/2026/08/08/xxx.png",
|
||||
"ornamentsPrice": 8200.00,
|
||||
"ornamentLevel": 3,
|
||||
"levelImg": "/profile/upload/level/red.png",
|
||||
"exteriorName": "略有磨损",
|
||||
"createTime": "2026-08-08 17:18:37"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `total` | Long | 总记录数 |
|
||||
| `pageNum` | Int | 当前页码 |
|
||||
| `pageSize` | Int | 每页条数 |
|
||||
| `rows` | Array | 记录列表,按中奖时间倒序 |
|
||||
| `rows[].id` | Long | 记录 ID |
|
||||
| `rows[].userId` | Int | 中奖用户 ID |
|
||||
| `rows[].nickName` | String | **中奖用户昵称**,用于公示展示 |
|
||||
| `rows[].avatar` | String | **中奖用户头像** |
|
||||
| `rows[].lotteryId` | Long | 宝箱 ID |
|
||||
| `rows[].lotteryName` | String | 宝箱名称 |
|
||||
| `rows[].prizeId` | Long | 奖品 ID |
|
||||
| `rows[].ornamentName` | String | 饰品名称 |
|
||||
| `rows[].imageUrl` | String | 饰品图片 |
|
||||
| `rows[].ornamentsPrice` | Decimal | 奖品价值 |
|
||||
| `rows[].ornamentLevel` | Int | 品质等级 ID |
|
||||
| `rows[].levelImg` | String | 品质底图,可作饰品卡片背景 |
|
||||
| `rows[].exteriorName` | String | 磨损度,可能为 `null` |
|
||||
| `rows[].createTime` | String | 中奖时间 `yyyy-MM-dd HH:mm:ss` |
|
||||
|
||||
失败时返回 `code = 500`,`msg` 为 `查询失败,请稍后重试`。
|
||||
|
||||
> **与 `myRecords` 的区别**:`myRecords` 只返回当前登录玩家自己的记录(不含昵称/头像/品质图);`globalRecords` 返回全服记录并附带中奖者昵称、头像、品质底图,专供公示展示。
|
||||
|
||||
> **轮询建议**:公示区如需实时滚动,建议 15~30 秒轮询一次 `pageNum=1`,不要高频请求。
|
||||
|
||||
---
|
||||
|
||||
## 五、典型调用流程
|
||||
|
||||
```
|
||||
进入抽奖页
|
||||
│
|
||||
├─→ POST /myStats
|
||||
│ 拿到 availableTurnover、lotteryList
|
||||
│ 渲染宝箱、奖品格子(爆率用 displayPercent)
|
||||
│ 显示可抽次数 lotteryChances
|
||||
│
|
||||
├─→ 玩家点击抽奖(按钮置灰防连点)
|
||||
│ POST /draw { lotteryId }
|
||||
│ ├─ code=200 → 用 prizeId 定位并播放动画
|
||||
│ │ 动画结束后重新 POST /myStats 刷新
|
||||
│ └─ code=500 → 按钮恢复,展示 msg
|
||||
│
|
||||
├─→ 查看我的中奖记录
|
||||
│ POST /myRecords { pageNum, pageSize }
|
||||
│
|
||||
└─→ 中奖公示区(跑马灯,15~30s 轮询)
|
||||
POST /globalRecords { pageNum: 1, pageSize: 20 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、对接注意事项
|
||||
|
||||
1. **不要传 userId**:所有接口的用户身份从 token 解析,传了也会被忽略。
|
||||
2. **`myStats` 无需 body**:之前该接口曾要求传 `{}`,现已改为无参,body 传或不传都可以。
|
||||
3. **次数按宝箱各算**:`lotteryChances` 在每个宝箱内单独计算(因为门槛可能不同),不是全局共享的次数。多个宝箱的次数是"同一份可用流水"的不同换算结果,抽任一宝箱都会消耗同一份流水。
|
||||
4. **抽奖不动余额**:客户端不要在抽奖后刷新余额,抽奖不产生金币/积分变化;只有分解饰品才会变。
|
||||
5. **动画与发奖解耦**:接口返回成功即已发奖入背包,动画播放期间活动被关闭不影响结果。
|
||||
6. **爆率字段区分**:给玩家看 `displayPercent`,`realPercent` / `realCount` 属于内部数据。
|
||||
7. **奖池自动重置**:当宝箱所有奖品剩余库存耗尽时,下一次抽奖会自动按后台配置的初始真实数量重置奖池,客户端无需处理。
|
||||
8. **全服记录已过滤脏数据**:`globalRecords` 服务端已排除机器人、白号、封禁及已删除用户,客户端直接展示即可。
|
||||
9. **公示区别高频轮询**:`globalRecords` 建议 15~30 秒轮询一次,`pageSize` 上限 100。
|
||||
|
||||
---
|
||||
|
||||
## 七、相关代码位置
|
||||
|
||||
| 内容 | 文件 |
|
||||
|------|------|
|
||||
| 用户端接口 | `skins-service/service-admin/src/main/java/com/ruoyi/admin/controller/TurnoverLotteryUserController.java` |
|
||||
| 业务实现 | `skins-service/service-admin/src/main/java/com/ruoyi/admin/service/impl/TurnoverLotteryServiceImpl.java` |
|
||||
| 全服记录查询 | `skins-service/service-admin/src/main/java/com/ruoyi/admin/mapper/TurnoverLotteryRecordMapper.java` |
|
||||
| 全服记录 VO | `skins-model/src/main/java/com/ruoyi/domain/vo/lottery/TurnoverLotteryWinVO.java` |
|
||||
| 奖品实体 | `skins-model/src/main/java/com/ruoyi/domain/entity/lottery/TtTurnoverLotteryPrize.java` |
|
||||
| 记录实体 | `skins-model/src/main/java/com/ruoyi/domain/entity/lottery/TtTurnoverLotteryRecord.java` |
|
||||
| 建表 SQL | `docs/updata_sql.md`(搜索"流水抽奖") |
|
||||
@@ -0,0 +1,707 @@
|
||||
# 玫瑰女郎 用户端 接口文档
|
||||
|
||||
---
|
||||
|
||||
## 一、功能说明
|
||||
|
||||
玫瑰女郎是一款**选箱下注 → 旋转 → 必得饰品**的电子游戏。核心产品逻辑:
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 三种玩法模式 | `normal` 普通旋转 / `ante` 底注 / `buy_fs` 购买免费旋转,互斥 |
|
||||
| 下注即得 | **开局即结算**:玩家扣费后,本轮结果与奖品在开局那一刻就已确定,饰品**直接写入背包**;前端播放动画仅是"揭晓" |
|
||||
| 无损模型 | 中途刷新 / 退出 / 断线,饰品已在背包,不会丢奖 |
|
||||
| 按身份差异化 | `common` 普通用户 / `anchor` 主播 / `robot` 机器人 / `white` 白号,各自独立的一套饰品桶分布与 RTP |
|
||||
| 1000x 封顶 | 总倍率 ≥ 1000x 视为已封顶,`maxWinReached=true`;**单局累计到账达 1000 倍即截断终止**,保证动画总赢额 = 实际到账 |
|
||||
| 自动分解 | 命中饰品默认写入背包;末帧由**前端**给玩家两个选择「放入背包 / 立即分解」。"自动分解"是前端自己的开关,开启后前端在末帧替玩家调一次分解接口即可,**后端不参与、也不回传该状态**;调不动(断网/超时)也只是饰品留在背包,玩家随时能自己分解,不会丢奖、不会重复入账 |
|
||||
| 消除规则 | 棋盘 5 行 × 6 列共 30 格;**同一符号满 8 个即消除**,按 8-9 / 10-11 / 12-30 三档赔付 |
|
||||
| 翻滚级联 | 消除 → 上方掉落 → 补位,可在同一旋内连锁多步,每步返回 `gridState`/`symbolLinks`(累积)/`dropData`/`fillData` |
|
||||
| 炸弹(乘数球) | **只在免费旋转内出现**(normal/ante 的基础旋、含进 FS 的触发盘一律没有炸弹);每屏最多 1 颗,乘数值 2~1000(见 `multiplierDetail`),FS 内跨旋累加 |
|
||||
| 免费旋转 | 基础盘 4+ SCATTER 触发,赠 15 旋(4/5/6 个 SCATTER 赔 3x/5x/100x);FS 内 3+ SCATTER 重触发 +5 旋,总上限 30 旋(到上限后不再落 3+ 个 SCATTER);**三种模式进 FS 后规则完全一致** |
|
||||
| 1000x 截断 | 单局累计到账达 1000 倍即截断终止(保证动画总赢额 = 实际到账) |
|
||||
|
||||
> 前端只需保证:**首次用 `boxId` 开局,之后不断调用 `继续` 逐步揭晓,直到 `state === "FINISH"`。**
|
||||
|
||||
---
|
||||
|
||||
## 二、通用约定
|
||||
|
||||
- **Base URL**:`{host}/api/olympus`
|
||||
- **请求方式**:全部为 `POST`
|
||||
- **Content-Type**:`application/json`
|
||||
- **鉴权**:需登录态,请求头携带 token(`Authorization: Bearer xxx`)。`userId` 由后端从 token 解析,**客户端不要传 userId**。
|
||||
|
||||
### 统一响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
| code | 含义 |
|
||||
|------|------|
|
||||
| 200 | 成功 |
|
||||
| 500 | 业务失败,提示语见 `msg`(直接向玩家展示) |
|
||||
| 701 | 未实名认证,客户端统一弹实名认证窗口(**玫瑰女郎接口不会返回该码**,统一约定保留,便于前端全局处理) |
|
||||
| 702 | 账户余额不足(`msg`:`账户余额不足`) |
|
||||
| 703 | 玩家未充值,不能游玩任何游戏(`msg`:`玩家未充值不能游玩任何游戏`) |
|
||||
|
||||
> 客户端判定成功以 `code === 200` 为准;失败时直接展示 `msg`。
|
||||
|
||||
### 玩法模式(boxType)
|
||||
|
||||
| boxType | modeName | 成本倍数 costMultiplier | 备注 |
|
||||
|---------|----------|------------------------|------|
|
||||
| `normal` | 普通旋转 | 1x | 标准玩法,饰品桶 0x-150x;基础旋不出炸弹,4+ SCATTER 自然进 FS(进 FS 后与买 FS 一致) |
|
||||
| `ante` | 底注 | 2x | SCATTER 概率提升(FS 触发 0.5%→1%),饰品桶 0x-200x;基础旋同样不出炸弹 |
|
||||
| `buy_fs` | 购买免费旋转 | 50x | 保证直接进入免费旋转,饰品桶 10x-1000x |
|
||||
|
||||
> 其中 `price`(单次下注实际扣费)= 箱子基础下注 `basicBet` × 该模式成本倍数。
|
||||
|
||||
---
|
||||
|
||||
## 三、符号与面值对照表
|
||||
|
||||
`gridState`、`symbolLinks`、`dropData`、`fillData` 中的符号一律是**短码**(单字母),前端按本节映射图标与文案。
|
||||
|
||||
### 3.1 普通符号(可消除)
|
||||
|
||||
| 短码 | 符号 code | 中文名 | 8-9 个 | 10-11 个 | 12-30 个 | 出现权重 |
|
||||
|------|-----------|--------|--------|----------|----------|----------|
|
||||
| `A` | CROWN | 皇冠 | 10x | 25x | 50x | 5 |
|
||||
| `B` | HOURGLASS | 沙漏 | 2.5x | 10x | 25x | 6 |
|
||||
| `C` | RING | 环 | 2x | 5x | 15x | 7 |
|
||||
| `D` | CHALICE | 杯赛 | 1.5x | 2x | 12x | 8 |
|
||||
| `E` | RED_GEM | 红宝石 | 1x | 1.5x | 10x | 9 |
|
||||
| `F` | PURPLE_GEM | 紫宝石 | 0.8x | 1.2x | 8x | 10 |
|
||||
| `G` | YELLOW_GEM | 黄宝石 | 0.5x | 1x | 5x | 10 |
|
||||
| `H` | GREEN_GEM | 绿宝石 | 0.4x | 0.9x | 4x | 10 |
|
||||
| `I` | BLUE_GEM | 蓝宝石 | 0.25x | 0.75x | 2x | 12 |
|
||||
|
||||
> 赔付以 **1 倍基础下注**为基准(接口里返回的金额字段均已再 ×bet);同一符号不足 8 个不成奖,最多计 30 个。
|
||||
|
||||
### 3.2 特殊符号
|
||||
|
||||
| 短码 | 符号 | 说明 |
|
||||
|------|------|------|
|
||||
| `S` | SCATTER | 不参与消除、不计入 `symbolLinks` 的消除统计;基础盘 4+ 触发免费旋转(4/5/6 个赔 3x/5x/100x,即 `scatterTrigger.scatterWin`),免费旋转内 3+ 重触发 +5 旋(总上限 30 旋,到上限后不再落 3+ 个 S) |
|
||||
| `M` | BOMB | 炸弹(乘数球),**乘数值不在短码里**,见 `multiplierDetail`;不参与消除、跨 cascade 保留,免费旋转内跨旋累加(`accumulator`)。**基础旋(`spinNum=0`,含触发盘)不会出现 `M`**;进 FS 后三种模式一致,每屏最多 1 颗 |
|
||||
|
||||
炸弹品质码(`multiplierDetail[].quality`):
|
||||
|
||||
| quality | 颜色 | 等级 | 乘数值 |
|
||||
|---------|------|------|--------|
|
||||
| `GREEN` | 绿 | 1 | 2 / 3 / 4 / 5 / 6 |
|
||||
| `BLUE` | 蓝 | 2 | 8 / 10 / 12 / 15 |
|
||||
| `PURPLE` | 紫 | 3 | 20 / 25 / 50 |
|
||||
| `RED` | 红 | 4 | 100 / 250 |
|
||||
| `RAINBOW` | 彩虹 | 5 | 500 / 1000 |
|
||||
|
||||
### 3.3 位置索引约定
|
||||
|
||||
`multiplierDetail[].pos`、`symbolLinks[].loc[]`、`dropData[].from`/`.to`、`fillData[].pos` 全部是**从 1 开始的格位号**,共 30 格:
|
||||
|
||||
```
|
||||
pos = 行序号(0 起) × 6 + 列序号(0 起) + 1 // 1 ~ 30,行=5、列=6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、接口清单
|
||||
|
||||
| # | 接口 | 路径 | 用途 |
|
||||
|---|------|------|------|
|
||||
| 1 | 获取箱子列表 | `POST /api/olympus/boxes` | 进入游戏页拉取可选箱子 |
|
||||
| 2 | 执行旋转(开始/继续) | `POST /api/olympus/dospin` | 开局扣费并返回第一步 / 继续揭晓下一步 |
|
||||
| 3 | 查询游戏记录(我的/全服,分页) | `POST /api/olympus/records` | `scope=1` 我的战绩 / `scope=0` 全服记录,均分页;**只含已结束的局** |
|
||||
| 4 | 查询进行中的游戏 | `POST /api/olympus/hasActive` | 断线重连时恢复动画 |
|
||||
| 5 | 背包分解(关联) | `POST /api/userPackSack/decompose` | 末帧「立即分解」(含前端自动分解) |
|
||||
|
||||
---
|
||||
|
||||
## 五、接口详情
|
||||
|
||||
### 1. 获取箱子列表
|
||||
|
||||
**`POST /api/olympus/boxes`**
|
||||
|
||||
进入游戏页时调用,返回当前可选的全部箱子(后端只返回启用 `status=1` 的箱子)。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
无,body 可不传(或传空对象 `{}`)。
|
||||
|
||||
#### 响应 data 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"list": [
|
||||
{
|
||||
"boxId": 1001,
|
||||
"boxName": "新手箱",
|
||||
"basicBet": 10.00,
|
||||
"price": 10.00,
|
||||
"boxType": "normal",
|
||||
"modeName": "普通旋转"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| boxId | Long | 箱子 ID,开局时作为 `boxId` 传入 dospin |
|
||||
| boxName | String | 箱子名称 |
|
||||
| basicBet | Decimal | 基础下注金额 |
|
||||
| price | Decimal | 单次实际扣费 = basicBet × 模式成本倍数 |
|
||||
| boxType | String | `normal`/`ante`/`buy_fs` |
|
||||
| modeName | String | 模式中文名(普通旋转/底注/购买免费旋转) |
|
||||
|
||||
---
|
||||
|
||||
### 2. 执行旋转(开始 / 继续)
|
||||
|
||||
**`POST /api/olympus/dospin`**
|
||||
|
||||
这是**核心接口**,有两种用法:
|
||||
|
||||
- **开始新游戏**:body 携带非空 `boxId` → 后端校验余额/充值/身份 → 扣费 → 选回合 → 返回**第一步**动画数据。
|
||||
- **继续游戏**:body 不带 `boxId`(传 `{}` 或 `{"boxId": null}`)→ 返回**下一步**动画数据,直到 `state === "FINISH"`。
|
||||
|
||||
> ⚠️ 继续时必须传一个 JSON body(至少 `{}`)。若完全不传 body,后端可能因读取不到 boxId 而报错。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
```json
|
||||
{
|
||||
"boxId": 1001
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| boxId | Long | 否 | **非空**=开始新游戏;**为空/null/不传**=继续当前游戏 |
|
||||
|
||||
#### 响应 data 结构(SpinResponse)
|
||||
|
||||
完整响应体(`@JsonInclude(NON_NULL)`,未出现的字段即为 `null`/未返回)。下面是**真实数据**的一步(box1 普通旋转 · 自然触发的免费旋转**第 6 旋 · 第 2 波**消除,屏上还留着一颗 2 倍炸弹;回合 `round 66551`,整局实付 125.45 倍底注、30 旋、42 步):
|
||||
|
||||
```json
|
||||
{
|
||||
"state": "PLAYING",
|
||||
"spinNum": 6,
|
||||
"tumbleNum": 2,
|
||||
"globalStep": 10,
|
||||
"globalTotalSteps": 42,
|
||||
"gridState": [
|
||||
["C","G","F","I","A","F"],
|
||||
["F","M","C","D","F","F"],
|
||||
["F","H","E","I","F","D"],
|
||||
["I","C","I","E","F","H"],
|
||||
["H","H","H","I","A","E"]
|
||||
],
|
||||
"multiplierDetail": [{"pos": 8, "val": 2, "quality": "GREEN"}],
|
||||
"symbolLinks": [
|
||||
{"symbol": "G", "count": 8, "loc": [3,5,10,13,18,25,26,30], "base_score": 0.50, "tumble": 1},
|
||||
{"symbol": "F", "count": 8, "loc": [3,6,7,11,12,13,17,23], "base_score": 0.80, "tumble": 2}
|
||||
],
|
||||
"dropData": [
|
||||
{"from": 19, "to": 25, "symbol": "H"},
|
||||
{"from": 7, "to": 19, "symbol": "I"},
|
||||
{"from": 1, "to": 13, "symbol": "F"},
|
||||
{"from": 20, "to": 26, "symbol": "H"},
|
||||
{"from": 14, "to": 20, "symbol": "C"},
|
||||
{"from": 8, "to": 14, "symbol": "H"},
|
||||
{"from": 2, "to": 8, "symbol": "M"},
|
||||
{"from": 4, "to": 10, "symbol": "D"},
|
||||
{"from": 24, "to": 30, "symbol": "E"},
|
||||
{"from": 12, "to": 24, "symbol": "H"},
|
||||
{"from": 6, "to": 18, "symbol": "D"}
|
||||
],
|
||||
"fillData": [
|
||||
{"pos": 1, "symbol": "C"}, {"pos": 2, "symbol": "G"}, {"pos": 3, "symbol": "F"},
|
||||
{"pos": 4, "symbol": "I"}, {"pos": 5, "symbol": "A"}, {"pos": 6, "symbol": "F"},
|
||||
{"pos": 7, "symbol": "F"}, {"pos": 12, "symbol": "F"}
|
||||
],
|
||||
"winAmount": 1.30,
|
||||
"spinWin": 14.55
|
||||
}
|
||||
```
|
||||
|
||||
读法(这一步):
|
||||
- 触发盘(`spinNum: 0`)**不会出现 `M`**:这一局基础旋摇出 4 个 S,白送 3.00 并进 FS(见下面买 FS 示例①)
|
||||
- `symbolLinks` 有 **2 条** —— `tumble:1` 是第 1 波(G 符号 8 连)、`tumble:2` 是第 2 波(F 符号 8 连);**本步新增的只有 `tumble === tumbleNum(2)` 那条**,另一条是给面板累加用的
|
||||
- `winAmount = 1.30` 是**本旋(第 6 旋)累计到本步的赢额**(= 0.50 + 0.80,中间波只记基础分),**不是本步增量**;**本旋末帧才把累计倍数乘上去** —— 这一旋末帧 `winAmount` 会跳到 11.70(本旋基础分 1.30 × (累计 7 + 本旋炸弹 2))
|
||||
- `spinWin = 14.55` 是**本局累计到本步的赢额**(触发盘 3.00 + 前 5 旋 10.25 + 本旋 1.30)
|
||||
- `dropData` 里 `{"from":2,"to":8,"symbol":"M"}`:**炸弹也随重力下落**(第 2 格 → 第 8 格),本旋末帧统一结算后才并入累计器(7 → 9)
|
||||
- `globalStep/globalTotalSteps` 由服务端按整局排帧给出
|
||||
|
||||
> 已删除的冗余字段:`accumulatedSymbols`、`spinWinners`、`removedPositions`、`baseScore` —— 替代算法见字段表下方的说明。
|
||||
|
||||
---
|
||||
|
||||
**下面用同一局(买FS bet=1、15 旋、整局实付 66.00 倍底注、共 27 步,回合 `round 93485`)看三个典型阶段**(真实数据):
|
||||
|
||||
##### ① 起始:本局第 1 步(触发盘,4 个 S 白送 3 倍)
|
||||
|
||||
```json
|
||||
{
|
||||
"state": "PLAYING",
|
||||
"spinNum": 0,
|
||||
"tumbleNum": 1,
|
||||
"globalStep": 1,
|
||||
"globalTotalSteps": 27,
|
||||
"gridState": [
|
||||
["B","G","S","C","G","I"],
|
||||
["I","E","H","E","G","S"],
|
||||
["D","B","G","I","S","C"],
|
||||
["G","H","E","B","F","S"],
|
||||
["B","C","E","A","C","G"]
|
||||
],
|
||||
"multiplierDetail": [],
|
||||
"symbolLinks": [],
|
||||
"dropData": [],
|
||||
"fillData": [],
|
||||
"scatterTrigger": {"scatterCount": 4, "freeSpins": 15, "scatterWin": 3.00},
|
||||
"winAmount": 3.00,
|
||||
"spinWin": 3.00
|
||||
}
|
||||
```
|
||||
- `spinNum: 0` = 触发盘(买FS 必出 4 个 S,这一盘没有任何符号连线和炸弹)
|
||||
- `winAmount = spinWin = 3.00`:触发盘这一旋的总额,也是本局累计的起点
|
||||
- `state` 仍是 `PLAYING` —— **本局还有 26 步**;`scatterTrigger` 出现在**本旋的末帧**,不是本局的末帧
|
||||
|
||||
##### ② 中间:第 4 旋 · 第 2 波(这一旋连消多波,屏上还留着一颗 2 倍炸弹)
|
||||
|
||||
```json
|
||||
{
|
||||
"state": "PLAYING",
|
||||
"spinNum": 4,
|
||||
"tumbleNum": 2,
|
||||
"globalStep": 10,
|
||||
"globalTotalSteps": 27,
|
||||
"gridState": [
|
||||
["G","A","G","G","G","G"],
|
||||
["I","E","G","I","C","G"],
|
||||
["D","G","D","G","I","G"],
|
||||
["I","E","I","C","M","E"],
|
||||
["D","I","E","C","B","E"]
|
||||
],
|
||||
"multiplierDetail": [{"pos": 23, "val": 2, "quality": "GREEN"}],
|
||||
"symbolLinks": [
|
||||
{"symbol": "G", "count": 14, "loc": [2,6,8,9,12,13,14,15,16,17,18,22,27,28], "base_score": 5.00, "tumble": 1},
|
||||
{"symbol": "G", "count": 10, "loc": [1,3,4,5,6,9,12,14,16,18], "base_score": 1.00, "tumble": 2}
|
||||
],
|
||||
"dropData": [
|
||||
{"from": 7, "to": 13, "symbol": "D"},
|
||||
{"from": 1, "to": 7, "symbol": "I"},
|
||||
{"from": 21, "to": 27, "symbol": "E"},
|
||||
{"from": 3, "to": 21, "symbol": "I"},
|
||||
{"from": 10, "to": 28, "symbol": "C"},
|
||||
{"from": 4, "to": 22, "symbol": "C"},
|
||||
{"from": 11, "to": 17, "symbol": "I"},
|
||||
{"from": 5, "to": 11, "symbol": "C"}
|
||||
],
|
||||
"fillData": [
|
||||
{"pos": 1, "symbol": "G"}, {"pos": 2, "symbol": "A"}, {"pos": 3, "symbol": "G"},
|
||||
{"pos": 4, "symbol": "G"}, {"pos": 5, "symbol": "G"}, {"pos": 6, "symbol": "G"},
|
||||
{"pos": 8, "symbol": "E"}, {"pos": 9, "symbol": "G"}, {"pos": 10, "symbol": "I"},
|
||||
{"pos": 12, "symbol": "G"}, {"pos": 14, "symbol": "G"}, {"pos": 15, "symbol": "D"},
|
||||
{"pos": 16, "symbol": "G"}, {"pos": 18, "symbol": "G"}
|
||||
],
|
||||
"winAmount": 6.00,
|
||||
"spinWin": 19.25
|
||||
}
|
||||
```
|
||||
- 本步新增的连线只有 `tumble === 2` 那条(G 10 连);`tumble: 1` 那条是这一旋第 1 波的(G 14 连,5.00),继续列在这里供"获胜者面板"累加
|
||||
- `dropData` 里那条 `{"from":...,"to":...,"symbol":"M"}` 会体现炸弹随重力下落,末帧才结算倍数(本例炸弹在 `gridState` 第 23 格)
|
||||
- `winAmount = 6.00` 是**第 4 旋累计**(5.00 + 1.00),`spinWin = 19.25` 是本局累计;这一旋末帧会跳到 `12.00`(基础分 6.00 × (累计 0 + 本旋炸弹 2))
|
||||
|
||||
##### ③ 最后一旋:本局第 27 步(`state = FINISH`,可结算展示)
|
||||
|
||||
```json
|
||||
{
|
||||
"state": "FINISH",
|
||||
"spinNum": 15,
|
||||
"tumbleNum": 1,
|
||||
"globalStep": 27,
|
||||
"globalTotalSteps": 27,
|
||||
"gridState": [
|
||||
["M","C","D","E","D","A"],
|
||||
["F","C","D","C","C","G"],
|
||||
["E","C","I","E","F","B"],
|
||||
["F","F","G","H","C","I"],
|
||||
["H","F","A","C","I","A"]
|
||||
],
|
||||
"multiplierDetail": [{"pos": 1, "val": 2, "quality": "GREEN"}],
|
||||
"symbolLinks": [],
|
||||
"dropData": [],
|
||||
"fillData": [],
|
||||
"winAmount": 0.00,
|
||||
"spinWin": 66.00,
|
||||
"totalWin": 66.00,
|
||||
"totalMul": 66.00,
|
||||
"prizeInfo": {
|
||||
"id": 4,
|
||||
"name": "M4A4 | 皇家圣骑士",
|
||||
"imageUrl": "/profile/upload/2026/07/02/TTRBNCB8IFJveWFsIFBhbGFkaW4gKEZhY3RvcnkgTmV3KQ==_20260702034203A004.png",
|
||||
"grade": 4,
|
||||
"exteriorName": "破损不堪",
|
||||
"isFallback": false
|
||||
},
|
||||
"boxRecordId": 556677
|
||||
}
|
||||
```
|
||||
- 第 15 旋**没有消成**:`symbolLinks` 空、`winAmount = 0`(屏上那颗 2 倍炸弹随本旋作废,累计器不变)
|
||||
- `spinWin = 66.00` = **整局实付**,与 `totalWin`/`totalMul` 一致(各旋末帧 `winAmount` 相加也等于它)
|
||||
- `prizeInfo` = 本局命中的饰品(按整局赢额落区间查表,见 3.4);`boxRecordId` = 服务端开局时就写入背包的记录 ID
|
||||
- 1000 倍封顶时才会多两个字段:`maxWinReached: true` + `spinsRemaining: 0`;本局没触顶,所以都不返回(`spinsRemaining` 平时**每帧都在**,封顶时被强制成 0)
|
||||
- 本局第 12 旋单旋赢了 22.75 倍(≥ 买 FS 的"小"阈值 20),那一旋的末帧会带 `bigWinLevel: "small"`;最后这一旋没消成(`winAmount = 0`),所以本步没有该字段 —— 该字段按**单旋**赢额判档、只在**免费旋转**里给,达标的那一旋末帧才出现
|
||||
|
||||
##### 字段说明
|
||||
|
||||
| 字段 | 类型 | 出现 | 说明 |
|
||||
|------|------|------|------|
|
||||
| state | String | 每步 | `PLAYING`(本局还有后续步骤)/ `FINISH`(**本局最后一步**,可结算展示)。注意:**本旋的末帧不一定是 FINISH**(只有整局最后一步才是),本旋是否结束看下一步 `spinNum` 是否变化 |
|
||||
| spinNum | Integer | 每步 | 免费旋转序号,**0 = 基础旋转** |
|
||||
| tumbleNum | Integer | 每步 | 本旋内的翻滚/消除序号 |
|
||||
| globalStep | Integer | 每步 | 全局第几步(从 1 开始) |
|
||||
| globalTotalSteps | Integer | 每步 | 本局总步数 |
|
||||
| spinsRemaining | Integer | 每步 | **剩余免费旋转次数**(已发放 − 已播完旋数):已发放 = 触发帧 `scatterTrigger.freeSpins`(15) + 已播步里的 `retriggerInfo.extraSpins`(5),封顶 30。**前端直接显示这个值倒计即可,不用自己加减**(口径见下方「剩余免费旋转」)。例:第 2 旋消完出 3 个 S → 15−2+5 = **18**。基础旋/无 FS 的局恒 0;1000x 封顶时强制 0 |
|
||||
| gridState | Array\[5\]\[6\] | 每步 | 棋盘短码矩阵(5 行 × 6 列,含 `S`/`M`);**每一步都是完整的 30 格,不会有空格/null**,是渲染的权威数据(约定见本节末尾) |
|
||||
| multiplierDetail | Array | 每步 | 炸弹明细 `[{"pos":1起,"val":乘数值,"quality":"GREEN/…"}]`,**未 × bet**(quality 见 3.2) |
|
||||
| symbolLinks | Array | 每步 | **本旋累积**的成奖连线 `[{"symbol","count","loc":[1起格位],"base_score","tumble"}]`;`base_score` 已 × bet,`tumble` = 这条连线属于本旋内第几波消除(= 本步 `tumbleNum`)。**本波新增的连线 = `tumble` 等于本步 `tumbleNum` 的那些**;获胜者面板按 `symbol` 汇总即可(原来单独的 `spinWinners` 字段已删) |
|
||||
| dropData | Array | 每步 | 掉落数据 `[{"from":1起,"to":1起,"symbol"}]`:只列**位置变了**的符号,`symbol` 是它落到目的地后的符号;新旋第一波为空数组 |
|
||||
| fillData | Array | 每步 | 补位数据 `[{"pos":1起,"symbol"}]`,**只含普通符号**(不含炸弹/SCATTER);正好盖住下落之后空出来的格子,新旋第一波为空数组 |
|
||||
| scatterTrigger | Object | 每步 | 基础盘触发免费旋转时返回 `{"scatterCount":4,"freeSpins":15,"scatterWin":3.00}`:`freeSpins` 恒 15 = **赠送量**(不是剩余,剩余看 `spinsRemaining`);`scatterWin` = 本盘 SCATTER 的**现金赔付**(已 × bet,4/5/6 个 S 分别赔 3x/5x/100x,已计入本旋 `winAmount`/`spinWin`),无则 null |
|
||||
| winAmount | Decimal | 每步 | **本旋累计到本步的赢额**(已 × bet):该旋**末帧(`isFinal=1`)的值就是这一旋的总额**(含炸弹倍数与 SCATTER),中间步是这一旋的累计进度;**不是本步增量**(本步增量 = 与上一步相减) |
|
||||
| spinWin | Decimal | 每步 | **本局累计到本步的赢额**(已 × bet):前端"总赢额跳字"直接用这个;**最后一步 = 整局实付 = `totalWin`** |
|
||||
| accumulator | Decimal | 每步 | **本步后的炸弹累计倍数**(未 × bet):免费旋转内跨旋传递的累计器(基础旋恒为 0);**断线重连时用 `lastStep.accumulator` 接回累计值**,其余时候前端可按 `multiplierDetail` 自行累计 |
|
||||
| retriggerInfo | Object | 每步 | 免费旋转内重触发 `{"scatterCount":3,"extraSpins":5,"scatterWin":0.00}`:`extraSpins` 恒 5 = **追加旋数**(30 旋封顶后仍会报 5 但实际不加,所以别拿它自己加减,直接用 `spinsRemaining`);`scatterWin` = 本旋 SCATTER 的**现金赔付**(已 × bet,4/5/6 个 S 分别赔 3x/5x/100x,**3 个 S 赔 0**,已计入本旋 `winAmount`/`spinWin`),无则 null |
|
||||
| bigWinLevel | String | 每步 | 爆奖档位(小/中/大):`small` / `middle` / `big`,无则 null。**只有免费旋转的旋末帧才可能有值**(normal/ante 的自然 FS、buy_fs 的每一旋),基础旋(`spinNum=0`)恒为 null;判档用该旋末帧的 `winAmount`,阈值见本节末尾「爆奖档位 `bigWinLevel` 阈值」 |
|
||||
| **totalWin** | Decimal | 仅 FINISH | 本局总赢额 |
|
||||
| **totalMul** | Decimal | 仅 FINISH | 本局总倍率 |
|
||||
| **maxWinReached** | Boolean | 仅 FINISH | 是否已达 1000x 封顶 |
|
||||
| **prizeInfo** | Object | 仅 FINISH | 命中饰品详情(见下) |
|
||||
| **boxRecordId** | Long | 仅 FINISH | 背包记录 ID,用于末帧「放入背包」或「立即分解」(分解时作为 `packSackIds` 传入) |
|
||||
|
||||
`prizeInfo` 结构:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | Long | 饰品(奖品)ID |
|
||||
| name | String | 饰品名称 |
|
||||
| imageUrl | String | 饰品图片(后台「奖品管理」的"饰品图片") |
|
||||
| levelImageUrl | String | **饰品等级/品质图片**(后台「奖品管理」的"品质图片",取 `tt_ornaments_level.level_img`,按饰品 `grade` 关联);前端结果弹窗的品质图标用它(可空) |
|
||||
| grade | Integer | 等级(= `tt_ornaments_level.id`) |
|
||||
| exteriorName | String | 外观名(可空) |
|
||||
| isFallback | Boolean | 是否命中兜底饰品(无精确区间时的 fallback) |
|
||||
|
||||
> 说明:`accumulator`(本步后炸弹累计倍数,未 × bet)为**跨旋传递的累计器**,基础旋恒为 `0`。
|
||||
> 前端按 `multiplierDetail` 自行累计即可;**唯一必须依赖它的是断线重连**——`hasActive` 只回最后一步,
|
||||
> 前几旋攒下的累计倍数不在 `lastStep.multiplierDetail` 里,必须用 `lastStep.accumulator` 把累计值接回来,
|
||||
> 否则重连后显示的累计倍数会从这次重连的那一旋重新起算(少掉之前累计的倍数)。
|
||||
>
|
||||
> **剩余免费旋转 `spinsRemaining`(每帧都返回,前端直接显示,不用自己算)**:
|
||||
> 进 FS 后从 **15** 起倒计,**每播完一旋 −1**(同一旋的中间波帧不变,到该旋**末帧**才减);
|
||||
> **那一旋出 3+ 个 S 时在同一帧 +5**(`retriggerInfo.extraSpins`;出 3/4/5/6 个 S 都是 +5,多出来的只是现金 `scatterWin` 3x/5x/100x,3 个 S 为 0)。
|
||||
> 所以"出 3 个 S"那一帧是 **−1 和 +5 一起结算 = 净 +4**:
|
||||
>
|
||||
> | 时刻 | 已发放 | 已播完 | `spinsRemaining` |
|
||||
> |---|---|---|---|
|
||||
> | 触发帧播完(进 FS) | 15 | 0 | **15** |
|
||||
> | 第 1 旋播完 | 15 | 1 | **14** |
|
||||
> | 第 2 旋·第 1/2 波(本旋还没播完) | 15 | 1 | 14(不变) |
|
||||
> | **第 2 旋末帧,这旋出 3 个 S** | 15+5 = 20 | 2 | **18**(= 14−1+5) |
|
||||
> | 第 3 旋播完 | 20 | 3 | **17** |
|
||||
> | 第 3 旋又出 4 个 S | 20+5 = 25 | 3 | **22** |
|
||||
>
|
||||
> 旋数上限 **30**(15 起、每次重触发 +5、最多加 3 次)—— 生成端已加闸门:**到 30 旋后不再落 3+ 个 S**,
|
||||
> 所以一局最多 3 次重触发(旧池子里还残留 650 个"报了 +5 但没有旋可加"的旧帧,重跑池子后会消失;
|
||||
> 后端的 `spinsRemaining` 按旋数夹 30,碰上旧帧也不会算错)。
|
||||
> 整局 1000x 封顶时强制 `spinsRemaining = 0`;无 FS 的局(基础旋空转)恒 0。
|
||||
>
|
||||
> **金额与面板字段怎么用**(都在 `steps[]` 的每一步上,金额已 × bet):
|
||||
>
|
||||
> | 想看什么 | 用哪个 |
|
||||
> |---|---|
|
||||
> | 这一步加了多少(本步增量) | `winAmount` 与上一步相减(第一步直接取 `winAmount`) |
|
||||
> | **这一旋一共赢了多少** | 该旋**末帧**的 `winAmount`(不用自己求和) |
|
||||
> | **本局一共赢了/正在累计多少** | `spinWin`(滚动跳字);最后一步的值 = `totalWin` |
|
||||
> | 本步新消出来的连线(画连线动画) | `symbolLinks` 里 `tumble` == 本步 `tumbleNum` 的那些 |
|
||||
> | 获胜者面板(本旋逐波的 符号×数量×赢额) | `symbolLinks` 按 `symbol` 汇总(`count` 累加、`base_score` 累加)即可,**已无单独的 `spinWinners` 字段** |
|
||||
> | 爆奖动画(small/middle/big 小/中/大) | `bigWinLevel`(后端按**单旋赢额**判好档,直接透传,无则 null;只在免费旋转里给) |
|
||||
> | 炸弹与倍数累计显示 | `multiplierDetail`(每屏炸弹 `pos/val/quality`),倍数按旋自行累加;断线重连后用 `lastStep.accumulator` 接回累计值 |
|
||||
>
|
||||
> 一致性:各旋末帧 `winAmount` 相加 = 整局实付 = 最后一步 `spinWin` = `totalWin`。
|
||||
> 已删除的字段(都是可从别的字段推导的冗余项):`baseScore`(= 本步 `symbolLinks[].base_score` 之和)、
|
||||
> `removedPositions`(= 上一步 `symbolLinks[].loc` 的并集)、`accumulatedSymbols`(= 本旋 `symbolLinks` 按 symbol 汇总)、
|
||||
> `spinWinners`(= 本旋 `symbolLinks` 按 symbol 汇总)。
|
||||
|
||||
> **盘面与动画数据的约定**(每一步都返回完整盘面,前端不用自己推算):
|
||||
>
|
||||
> 1. `gridState` 永远是**权威盘面**:5 行 × 6 列 = 30 格,格子里只有 `A`~`I` / `S` / `M`,**不会出现空格或 null**(全库 355,902 步已逐一校验:空白格 0、非法符号 0、复刻结果与实际盘面 100% 一致)。
|
||||
> 2. 同一旋内,第 N+1 波可以**精确**复刻:`上一波盘面` −(上一波 `symbolLinks` 里 `tumble == 上一波 tumbleNum` 的格子)→ 按 `dropData` 下落 → 按 `fillData` 补位 = `gridState`(`dropData` 与重力、`fillData` 覆盖的格子也逐条校验过)。
|
||||
> 3. `dropData[].symbol` / `fillData[].symbol` 一律是**这一波结束后**该位置的符号(与 `gridState` 同位置一致),所以按第 2 条落地后不会出现错符号。
|
||||
> 4. `dropData` 只列**位置发生变化**的符号(原地不动的格子不在里面,前端保留原样即可);`fillData` 正好覆盖"下落之后空出来的格子",不会漏格。
|
||||
> 5. **新旋的第一波**(`tumbleNum = 1`,`spinNum` 递增或进入本局第 1 步)`dropData` / `fillData` 都是空数组 —— 这一波是**整盘重发**,前端直接整盘替换/重新发牌,不要按下落补位处理。
|
||||
> 6. 免费旋转的"保底消除"让 8 个**刚落下/刚补位**的格子凑成一组消除:这些格子的新符号就在 `dropData` / `fillData` 里(见第 3 条),所以整条动画链能完整复刻。前端每一步结束都以 `gridState` 为准渲染,永远不会出现空格。
|
||||
|
||||
> **免费旋转的累计倍数**(生成端规则,前端显示不受影响):
|
||||
> 免费旋转内,**每一旋都可能带炸弹**(每屏最多 1 颗;低倍档局只出 2~8 的小球、高倍档局可出到 1000),
|
||||
> 有消除的旋会把本旋炸弹并入累计器,**本旋赢额 = 基础分 × max(1, 累计值)**,之后累计值 += 本旋炸弹和
|
||||
> —— 也就是"滚雪球":上一旋攒的倍数会作用到后面每一次消除。
|
||||
> 因为累计倍数会放大后续每一次消除,**低倍档局(10x~25x,占买FS 抽中率 76%)大约只能演 5 次左右的
|
||||
> "消除+爆"**(单个球越小、能买的次数越多);要 10 次以上的连爆,那一局本身就得是 40 倍以上的档位。
|
||||
> 表现上:15 旋里约 5 旋有消除、约 4 次炸弹并入、局末累计倍数常见 15~25(大奖局 100+)。
|
||||
>
|
||||
> **normal/ante 的自然免费旋转**:基础旋(`spinNum=0`)不出炸弹,摇出 4~6 个 SCATTER 才进 FS,
|
||||
> 进 FS 后走的是**和买 FS 完全相同**的规则(同样的炸弹密度、同样的累计器、同样的每旋保底消除),
|
||||
> 这样一局实测约 80~120 倍底注、5 旋左右有消除。因为 15 旋带炸弹的整局必然是这个量级,
|
||||
> **这两个模式的 100x / 150x / 200x 饰品桶就是靠"自然触发 FS"的回合来装的** ——
|
||||
> 前端不需要区分,`spinNum` 从 0 变成 1 就是进了 FS。
|
||||
|
||||
**爆奖档位 `bigWinLevel` 阈值**(小/中/大三档,按**单旋**赢额 ÷ 基础下注判档,达标即在该旋末帧返回、同档一局可多次;**只判免费旋转**,基础旋恒为 null):
|
||||
|
||||
| 情况 | 模式 | `small`(小) | `middle`(中) | `big`(大) |
|
||||
|------|------|------|------|------|
|
||||
| ① 自然免费旋转 | `normal` / `ante` | ≥5 | ≥25 | ≥100 |
|
||||
| ② 购买免费旋转 | `buy_fs` | ≥20 | ≥60 | ≥200 |
|
||||
|
||||
- 判档取值:该旋末帧的 `winAmount`(单位 = 基础下注倍数,与前端看到的一致)。
|
||||
- 前端逻辑:**只在 `bigWinLevel` 非空时播对应动画**,播完继续下一步即可;一个 FS 局里三档可以按旋依次出现。
|
||||
- 为什么两套阈值:normal/ante 的自然 FS 整局约 80~130 倍(预算/旋数完全一致,共用一套);`buy_fs` 花 50 倍买来的赢额分散在 15+ 旋里,单旋阈值整体下调。
|
||||
- 实测出现频率(当前池子 30 箱逐局统计):① 自然 FS(normal 519 局 / ante 818 局)—— "小" 91.7% / 93.5% 的局会出现(每局 2.3~2.5 次)、"中" 49.3% / 48.8%(0.6 次)、"大" 31.4% / 39.0%(0.3~0.4 次);② 买 FS(12,205 局)—— "小" 19.5% 的局出现(0.47 次)、"中" 6.3%(0.13 次)、"大" **1.5%**(0.02 次)。
|
||||
|
||||
##### 错误码 / 业务提示
|
||||
|
||||
| code | msg | 触发场景 | 前端处理 |
|
||||
|------|-----|---------|---------|
|
||||
| 702 | 账户余额不足 | 余额 < 单次扣费 | 提示并引导充值 |
|
||||
| 703 | 玩家未充值不能游玩任何游戏 | 从未充值 | 提示先充值 |
|
||||
| 500 | 网站维护中...... | 全站维护开启 | 提示维护 |
|
||||
| 500 | 玫瑰女郎维护中...... | 玫瑰女郎维护开启 | 提示维护 |
|
||||
| 500 | 请求频繁,请稍后再试 | 短时重复请求(Redis 锁) | 稍后重试 |
|
||||
| 500 | 箱子不可用 | boxId 不存在或下线 | 刷新箱子列表 |
|
||||
| 500 | 该箱子暂无可用回合 | 该身份该箱无可用回合 | 提示稍后再试 |
|
||||
| 500 | 游戏已结束 | 局已播完仍继续调用 | 忽略/返回首页 |
|
||||
| 500 | boxId 不能为空 | 无进行中对局,且 body 未带 `boxId` | 回到选箱页重新开局 |
|
||||
|
||||
> **本接口不会返回 701**(实名认证由支付等其它模块返回,游戏链路不做实名校验)。
|
||||
|
||||
> **维护只拦"新开一局"**:当 `websiteMaintenance`/`olympusMaintenance` 开启时,只要**携带非空 `boxId`(开新局)**就会返回维护提示;**不带 `boxId`(继续当前对局)不受维护影响**,可正常播完。进行中的对局不会被强制结算。
|
||||
|
||||
---
|
||||
|
||||
### 3. 查询游戏记录(我的 / 全服,均分页)
|
||||
|
||||
**`POST /api/olympus/records`**
|
||||
|
||||
一个接口通过 `scope` 区分「我的记录」与「全服记录」,**两者均支持分页**。
|
||||
|
||||
- `scope=1`(默认):**我的记录**,只返回当前登录玩家已结束(播完)的记录。
|
||||
- `scope=0`:**全服记录**,返回全站玩家已结束(播完)的记录(倒序)。
|
||||
- **白号记录在两种 scope 下都会出现(可上榜)**;全服口径仅排除机器人(user_type=03)、封禁(status=1)与已删除(del_flag!=0)用户。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
```json
|
||||
{
|
||||
"scope": 0,
|
||||
"pageNum": 1,
|
||||
"pageSize": 10
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| scope | Integer | 否 | `1`=我的记录(默认)/ `0`=全服记录 |
|
||||
| pageNum | Integer | 否 | 页码,默认 `1` |
|
||||
| pageSize | Integer | 否 | 每页条数,默认 `10` |
|
||||
|
||||
> 请求体为 **`RecordPageRequest` 实体类**(POST + JSON),三个字段均可选;body 也可不传(或传空对象 `{}`),此时等价于 `scope=1, pageNum=1, pageSize=10`(我的记录)。
|
||||
|
||||
#### 响应 data 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"rows": [
|
||||
{
|
||||
"nickName": "玩家昵称",
|
||||
"avatar": "https://.../avatar.png",
|
||||
"cost": 10.00,
|
||||
"settledWin": 88.00,
|
||||
"prizeImageUrl": "https://.../rose.png",
|
||||
"prizeName": "黄金玫瑰 (久经沙场)",
|
||||
"createTime": "2024-01-01 12:00:00"
|
||||
}
|
||||
],
|
||||
"total": 57
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| nickName | String | 玩家昵称(我的记录=自己,全服记录=中奖玩家) |
|
||||
| avatar | String | 玩家头像 |
|
||||
| cost | Decimal | 用户此次消费价格(本局扣费) |
|
||||
| settledWin | Decimal | 此次获得饰品的价格(本局结算金额) |
|
||||
| prizeImageUrl | String | 饰品图片 |
|
||||
| prizeName | String | 饰品全名:`名称 (磨损品质)`,无品质时仅名称(如 `黄金玫瑰 (久经沙场)`) |
|
||||
| createTime | String | 时间(yyyy-MM-dd HH:mm:ss,东八区) |
|
||||
|
||||
> 列表项**只返回上述 7 个字段**(我的记录/全服记录同一结构):`userId`/`boxId`/`roundId`/`settled`/`modeType`/`rewardType`/`prizeId`/`prizeLevelImg`/`settleSource`/`boxName`/`basicBet` 等字段已不再返回,客户端无需判空。
|
||||
>
|
||||
> **只返回已结束的局**:开局即结算(`settled=1`)但客户端还在播动画的那一局不出现在列表里,播完末帧后(服务端把该记录 `settle_source` 由 `start` 改为 `finish`)才进入记录;中途退出/断线未播完的局,按单局缓存 TTL **30 分钟**兜底,超时后照常展示,战绩不会丢。
|
||||
>
|
||||
> `total` 为**该 scope 下的真实总数**(可用于分页计算)。
|
||||
>
|
||||
> 早期版本曾有 `autoDismantle` 字段,现已彻底移除(自动分解由前端开关 + 前端调分解接口,后端不记录、不返回);库里 `game_olympus_record.auto_dismantle` 列作为历史列保留,无代码读写。
|
||||
|
||||
---
|
||||
|
||||
### 4. 查询进行中的游戏(断线重连)
|
||||
|
||||
**`POST /api/olympus/hasActive`**
|
||||
|
||||
用户刷新页面/重连时调用,判断是否有未播完的局,若有则返回当前进度与最后一步,供前端恢复动画。**维护期间不强制结算**,进行中的对局可正常重连并播完。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
无,body 可不传(或传空对象 `{}`)。
|
||||
|
||||
#### 响应 data 结构
|
||||
|
||||
**无进行中游戏:**
|
||||
```json
|
||||
{ "hasActive": false }
|
||||
```
|
||||
|
||||
**有进行中游戏:**
|
||||
```json
|
||||
{
|
||||
"hasActive": true,
|
||||
"boxId": 1001,
|
||||
"boxType": "normal",
|
||||
"bet": 10.00,
|
||||
"cost": 10.00,
|
||||
"stepIndex": 3,
|
||||
"totalSteps": 8,
|
||||
"freeSpinCount": 0,
|
||||
"totalWin": 25.00,
|
||||
"roundId": 1234567,
|
||||
"lastStep": { "...": "与 dospin 的 PLAYING 响应同构,为最后已播的一步" }
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| hasActive | Boolean | 是否有进行中局 |
|
||||
| boxId | Long | 箱子 ID |
|
||||
| boxType | String | 模式 |
|
||||
| bet | Decimal | 基础下注 |
|
||||
| cost | Decimal | 本局扣费(已扣) |
|
||||
| stepIndex | Integer | 当前已播步数 |
|
||||
| totalSteps | Integer | 总步数 |
|
||||
| freeSpinCount | Integer | 本局免费旋转**总**次数(15 + 重触发×5,封顶 30;1000x 截断局是实际执行数)。**不是剩余** |
|
||||
| spinsRemaining | Integer | **剩余免费旋转次数**(倒计值,= 最后已播一步的 `lastStep.spinsRemaining`,口径见「2. 执行旋转」的字段说明),无 FS 的局为 0 |
|
||||
| totalWin | Decimal | 已播步骤累计赢额 |
|
||||
| roundId | Long | 回合 ID |
|
||||
| lastStep | Object | 最后已播一步的完整动画数据(PLAYING 同构);**仅当 `stepIndex ≥ 1` 时返回**,`stepIndex=0` 时该字段不出现。`lastStep.accumulator` = 这步之后的炸弹累计倍数,重连时用它接回累计 |
|
||||
|
||||
> 前端恢复策略:先播 `lastStep` 的最后一帧,然后**把本地炸弹累计倍数重置为 `lastStep.accumulator`**(字段不出现时按 0),再继续调用 `dospin`(不传 boxId)走完剩余步骤,直至 FINISH。少了这一步重置,重连后累计倍数会从重连那一旋重新起算。
|
||||
|
||||
---
|
||||
|
||||
### 5. 背包分解(关联接口)
|
||||
|
||||
**`POST /api/userPackSack/decompose`**
|
||||
|
||||
玫瑰女郎的命中饰品默认以 `ornament` 形式写入背包(`tt_box_records.source = OLYMPUS_BOX(13)`)。末帧玩家点「立即分解」——或前端"自动分解"开关开启时代替玩家自动调用——都调这个通用背包接口把饰品兑换成金币/积分;接口没调到也只是饰品留在背包,无资金影响。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
```json
|
||||
{
|
||||
"packSackIds": [556677],
|
||||
"isAll": false,
|
||||
"decomposeType": "game"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| packSackIds | Array<Long> | 否 | 待分解的背包记录 ID 列表(单个即 `[boxRecordId]`) |
|
||||
| isAll | Boolean | 是 | 是否全部分解(`false`=按 ids) |
|
||||
| decomposeType | String | 否 | `backpack`=背包分解(默认)/ `game`=游戏结果页立即分解。**后端当前不读取该字段**(分解结算一律按饰品来源判定),传与不传结果相同,保留仅为兼容前端调用 |
|
||||
|
||||
#### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "成功分解1件饰品",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
> **分解流水口径**:分解结算按**饰品来源**自动分组记流水(`tt_box_records.source` → 对应游戏奖励流水),与前端传来的 `decomposeType` 无关(该字段后端不读取,传与不传结果相同)。玫瑰女郎饰品来源 `OLYMPUS_BOX(13)` → **`OLYMPUS_REWARD(61) 玫瑰女郎奖励`**:无论玩家「放入背包后再分解」还是末帧「立即分解」(含前端自动分解),入账流水一律是玫瑰女郎奖励。
|
||||
|
||||
---
|
||||
|
||||
## 六、关键流程(前端友好版)
|
||||
|
||||
### 6.1 进入游戏页
|
||||
|
||||
1. `POST /api/olympus/boxes` → 拿到箱子列表,展示箱子和价格。
|
||||
2. 玩家选箱子 → 记录 `boxId`。
|
||||
|
||||
### 6.2 开始新游戏
|
||||
|
||||
1. `POST /api/olympus/dospin`,body `{"boxId": X}`。
|
||||
2. 返回第一步(`PLAYING` 或直接 `FINISH`,若该局只有一步)。
|
||||
|
||||
### 6.3 逐步揭晓动画
|
||||
|
||||
- 对每步 `PLAYING` 响应,播放对应动画(`gridState`/`symbolLinks` 里 `tumble === tumbleNum` 的新增连线/`dropData`/`fillData`/`multiplierDetail`)。
|
||||
`winAmount` 是该旋累计(末帧即该旋总额),`spinWin` 是本局累计,可直接用于跳字。
|
||||
- 再调用 `POST /api/olympus/dospin`,body `{}`(不带 boxId),得到下一步。
|
||||
- 重复直止 `state === "FINISH"`。
|
||||
|
||||
### 6.4 末帧结算展示
|
||||
|
||||
1. 读到 `totalWin/totalMul/prizeInfo/boxRecordId`。
|
||||
2. **饰品已写入背包**,无需等待。
|
||||
3. 展示中奖弹窗,给玩家两个选择:
|
||||
- **放入背包**:什么都不用调,饰品已在背包(`boxRecordId`)。
|
||||
- **立即分解**:调 `POST /api/userPackSack/decompose`,`packSackIds:[boxRecordId], isAll:false`,入账流水为玫瑰女郎奖励(61)。
|
||||
4. 前端"自动分解"开关为开时:末帧直接按第 3 步的「立即分解」调用,等价于替玩家点了一下,**后端无需任何额外字段或开关**。
|
||||
5. 这次调用失败(断网、超时、玩家直接关页面)都不影响对局结果:饰品本来就在背包里,等于玩家选了「放入背包」,之后在背包页随时可再分解。重试也不会重复加钱(同一饰品只会被结算一次)。
|
||||
6. **结算站内信在"这一局播完"时才发**(多步局=末帧 `FINISH`,单步局=开局):开局就发会在动画播完前把结果剧透给玩家。因此中途退出/断线再没回来的局不发站内信,饰品仍在背包、战绩仍按 30 分钟兜底展示。
|
||||
|
||||
### 6.5 重连 / 刷新
|
||||
|
||||
- 调 `POST /api/olympus/hasActive`。
|
||||
- `hasActive=false` → 正常回首页/列表。
|
||||
- `hasActive=true` → 先播 `lastStep`,再继续 `dospin`(不带 boxId)走完剩余步骤。
|
||||
|
||||
### 6.6 维护行为
|
||||
|
||||
- **开启维护(`websiteMaintenance`/`olympusMaintenance`)= 只拦"新开一局"**。
|
||||
- 玩家携带非空 `boxId` 调 `dospin`(开新局)→ 返回维护提示(`网站维护中......` / `玫瑰女郎维护中......`)。
|
||||
- **进行中的对局不受影响**:不带 `boxId` 调 `dospin`(继续)可正常播完;`hasActive` 也能正常恢复动画,不会被强制结算。
|
||||
|
||||
---
|
||||
|
||||
## 七、数据口径补充
|
||||
|
||||
- **扣费来源流水**:开局扣费写入账户流水 `source = 60`(`TtAccountRecordSource.OLYMPUS_SPIN`,玫瑰女郎消费)。
|
||||
- **奖励流水**:`source = 61`(`TtAccountRecordSource.OLYMPUS_REWARD`,玫瑰女郎奖励)。**只在玩家分解饰品时入账**:无论「放入背包后再分解」还是末帧「立即分解」(含前端自动分解),口径完全一致;开局本身不发奖励流水。不会重复入账——同一饰品记录只结算一次(`status IN (0,2)` 状态机 + `user:decompose:lock` 锁)。
|
||||
- **背包记录来源**:命中饰品写入 `tt_box_records`,`source = OLYMPUS_BOX(13)`,`box_name = "OLYMPUS"`,`status=0`(我的库存)。
|
||||
- **身份维度(audience)**:`common` 普通 / `anchor` 主播(user_type=01) / `robot` 机器人(user_type=03) / `white` 白号(is_blank_account=1)。白号优先级最高,压过 user_type。
|
||||
- **注意**:白号一般为主播测试/展示用,其玩局不注水、不扣水、不受水位约束,前端无需特殊处理。
|
||||
- **1000x 封顶**:单局总倍率 ≥ 1000 即 `maxWinReached=true`,`spinsRemaining=0`;到账同样按 1000 倍截断。
|
||||
- **游戏参数(速查)**:棋盘 5×6=30 格;同符号 ≥8 消除(8-9/10-11/12-30 三档);**炸弹只在免费旋转内出现**(每屏最多 1 颗,2~1000 倍,绿/蓝/紫/红/彩虹 5 档品质;normal/ante 的基础旋无炸弹);基础盘 4+ SCATTER 触发 15 旋免费旋转(三模式进 FS 后规则一致),FS 内 3+ 再 +5 旋,上限 30 旋(到上限后不再落 3+ 个 S)。
|
||||
@@ -0,0 +1,727 @@
|
||||
# 疯狂转盘 前端接口文档
|
||||
|
||||
> **版本**:v2.0
|
||||
> **更新时间**:2025-01
|
||||
> **适用前端**:Web/H5/小程序
|
||||
> **Base URL**:`{host}/api/wheel`
|
||||
|
||||
---
|
||||
|
||||
## 📋 目录
|
||||
|
||||
1. [玩法概述](#一玩法概述)
|
||||
2. [通用约定](#二通用约定)
|
||||
3. [接口清单](#三接口清单)
|
||||
4. [接口详情](#四接口详情)
|
||||
5. [时间轴使用指南](#五时间轴使用指南)
|
||||
6. [前端对接要点](#六前端对接要点)
|
||||
|
||||
---
|
||||
|
||||
## 一、玩法概述
|
||||
|
||||
疯狂转盘是一款 **选颜色+符号 → 下注 → 转盘停 → 命中得饰品** 的真人轮盘游戏。
|
||||
|
||||
### 核心规则
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **盘面结构** | 内外圈各 24 格,索引一一对应<br>• 外圈符号:固定布局(A金/B红/C紫/D蓝)<br>• 内圈颜色:每轮随机排布(金6/红8/紫10) |
|
||||
| **下注方式** | 选 1 个「颜色×符号」组合 + 1 个下注额<br>**每轮每人最多 50 注** |
|
||||
| **下注额档位** | 10 档:5 / 10 / 20 / 50 / 100 / 200 / 500 / 1000 / 2000 / 5000 元 |
|
||||
| **倍率** | 12 个倍率(3 色 × 4 符号),所有档位共用<br>例:gold+D 倍率=22,押 100 元中奖得 2200 元 |
|
||||
| **命中判定** | 下注的 `color+symbol` == 开奖的 `resultColor+resultSymbol` |
|
||||
| **中奖奖励** | 饰品价值 = **下注额 × 倍率**<br>直接进背包,无需选择"保留/分解" |
|
||||
| **未中奖励** | 每注发 **0.01 元安慰饰品**(保证每注必有产出) |
|
||||
| **决策池** | 单一真实奖池,所有人同一个落点<br>• `real`(真实池,参与落点/RTP 决策)= 普通(02)+主播(01)+招商(04)+卡商(05)<br>• `display`(展示,可下注/返奖但不入真实池)= 机器人(03)+白号(06) |
|
||||
|
||||
### 一局生命周期
|
||||
|
||||
```
|
||||
newRound 创建
|
||||
↓
|
||||
下注期 (30s) ← 玩家可调用 /dobet 下注
|
||||
↓
|
||||
封盘期 (2s) ← 不再接受下注,倒计时"即将开奖"
|
||||
↓
|
||||
开奖 + 动画 (7s) ← 后端出结果,前端播转盘动画
|
||||
↓
|
||||
结果展示 → 下一局
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、通用约定
|
||||
|
||||
### 2.1 请求方式
|
||||
|
||||
- **协议**:HTTPS
|
||||
- **Method**:全部 `POST`
|
||||
- **Content-Type**:`application/json`
|
||||
- **鉴权**:`Authorization: Bearer {token}`(登录后从响应头或登录接口获取)
|
||||
|
||||
> ⚠️ **重要**:`userId` 由后端从 token 解析,**客户端不要传 userId 参数**。
|
||||
|
||||
### 2.2 统一响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
| code | 含义 | 前端处理 |
|
||||
|------|------|----------|
|
||||
| 200 | 成功 | 正常处理 data |
|
||||
| 500 | 业务失败 | Toast 显示 msg |
|
||||
| 701 | 未实名认证 | 弹实名认证弹窗 |
|
||||
| 702 | 账户余额不足 | 提示充值 |
|
||||
| 703 | 玩家未充值,不能游玩 | 引导首充 |
|
||||
|
||||
### 2.3 货币单位
|
||||
|
||||
- 所有金额字段为 **String 类型**,单位:**元**(精确到小数点后 2 位)
|
||||
- 示例:`"balance": "9800.50"` 表示 9800.50 元
|
||||
|
||||
### 2.4 时间格式
|
||||
|
||||
- **时间戳**:`Long` 类型,**秒级** Unix 时间戳(如 `1738300000`)
|
||||
- **时间字符串**:`yyyy-MM-dd HH:mm:ss`(东八区)
|
||||
|
||||
---
|
||||
|
||||
## 三、接口清单
|
||||
|
||||
| # | 接口名称 | 路径 | 用途 | 调用时机 |
|
||||
|---|---------|------|------|---------|
|
||||
| 1 | ~~获取游戏配置~~ | ~~`/game_config`~~ | **已废弃**,数据已合并到 `/last_round_info` | ~~进入游戏页时调用 1 次~~ |
|
||||
| 2 | 投注 | `/dobet` | 下注一笔 | 玩家点击下注按钮 |
|
||||
| 3 | 获取当前局状态 | `/last_round_info` | **一次拿全**:配置+状态+开奖+下注 | **轮询**(1-2 秒间隔) |
|
||||
| 4 | 我的下注记录 | `/history_bet_info` | 我的全部下注记录(中奖+未中奖,分页) | 打开"记录"页 |
|
||||
| 5 | 全服中奖记录 | `/round_history_bet_info` | 全服中奖公示(只含真正命中,分页) | 打开"中奖榜"页 |
|
||||
| 6 | 历史开奖结果 | `/round_results` | 每一轮的开奖符号(分页) | 打开"历史开奖"页 |
|
||||
|
||||
---
|
||||
|
||||
## 四、接口详情
|
||||
|
||||
### 4.1 ~~获取游戏配置~~ (已废弃)
|
||||
|
||||
> **⚠️ 已废弃**:本接口数据已全部合并到 `/last_round_info`,前端只需轮询一个接口即可。
|
||||
> 旧接口保留仅为兼容旧版前端,新前端请直接使用 `/last_round_info`。
|
||||
|
||||
---
|
||||
|
||||
### 4.2 投注
|
||||
|
||||
**`POST /api/wheel/dobet`**
|
||||
|
||||
下注一笔。**每轮每人上限 50 注**。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
```json
|
||||
{
|
||||
"bet": 100,
|
||||
"color": "gold",
|
||||
"symbol": "D"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `bet` | Integer | ✅ | 下注额,必须是 10 档之一 |
|
||||
| `color` | String | ✅ | 内圈色:`gold` / `red` / `purple` |
|
||||
| `symbol` | String | ✅ | 外圈符:`A` / `B` / `C` / `D` |
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"roundId": 10086,
|
||||
"cost": "100.00",
|
||||
"balance": "9700.50",
|
||||
"credits": "1200.00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `roundId` | Long | 本回合 ID |
|
||||
| `cost` | String | 本次扣款(= 下注额) |
|
||||
| `balance` | String | 下注后余额(金币) |
|
||||
| `credits` | String | 下注后积分 |
|
||||
|
||||
#### 常见错误
|
||||
|
||||
| code | msg | 场景 |
|
||||
|------|-----|------|
|
||||
| 500 | 网站维护中...... | 全站维护 |
|
||||
| 500 | 疯狂转盘维护中...... | 转盘维护 |
|
||||
| 500 | 游戏不在投注期 | 已封盘/开奖中 |
|
||||
| 500 | 每轮最多下注50注 | 超出上限 |
|
||||
| 500 | 该下注组合未配置奖品 | 矩阵缺失(联系运营) |
|
||||
| 702 | 账户余额不足 | 需充值 |
|
||||
| 703 | 玩家未充值不能游玩任何游戏 | 需首充 |
|
||||
|
||||
---
|
||||
|
||||
### 4.3 获取当前局状态
|
||||
|
||||
**`POST /api/wheel/last_round_info`**
|
||||
|
||||
**核心接口**,轮询调用(建议 1-2 秒间隔),**一次返回全部数据**:游戏配置 + 当前回合状态 + 开奖结果 + 我的下注。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"roundId": 10086,
|
||||
"periodNo": 202501011,
|
||||
"serverTime": 1738300050,
|
||||
"startTime": 1738300000,
|
||||
"bettedTime": 1738300030,
|
||||
"spinTime": 1738300032,
|
||||
"endTime": 1738300039,
|
||||
"status": "spined",
|
||||
|
||||
"outerSymbols": ["A","D","C","D","B","D","C","D","B","C","D","C","A","D","C","D","C","D","B","D","C","D","C","D"],
|
||||
"bets": [5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000],
|
||||
"myPoolType": "real",
|
||||
"rewardMatrix": [
|
||||
{
|
||||
"betAmount": 5,
|
||||
"color": "gold",
|
||||
"symbol": "D",
|
||||
"multiplier": 22,
|
||||
"prizeValue": "110.00",
|
||||
"prizeName": "黄金玫瑰",
|
||||
"prizeGrade": 3,
|
||||
"prizeExterior": "崭新出厂",
|
||||
"prizeImage": "https://example.com/prize.png",
|
||||
"prizeLevelImg": "https://example.com/level3.png"
|
||||
}
|
||||
],
|
||||
|
||||
"innerColors": ["gold","gold","red","purple","purple","red","gold","purple","red","purple","gold","purple","red","purple","gold","gold","purple","purple","red","gold","purple","red","purple"],
|
||||
"resultIndex": 5,
|
||||
"resultColor": "gold",
|
||||
"resultSymbol": "D",
|
||||
"totalWin": "2200.00",
|
||||
"userBets": [
|
||||
{
|
||||
"color": "gold",
|
||||
"symbol": "D",
|
||||
"count": 3,
|
||||
"totalBet": "300.00"
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"symbol": "A",
|
||||
"count": 1,
|
||||
"totalBet": "50.00"
|
||||
}
|
||||
],
|
||||
"winResult": [
|
||||
{
|
||||
"color": "gold",
|
||||
"symbol": "D",
|
||||
"hit": true,
|
||||
"boxRecordId": 123456,
|
||||
"prizeName": "黄金玫瑰",
|
||||
"prizeImage": "https://example.com/prize.png",
|
||||
"prizeGrade": 5,
|
||||
"prizeLevelImg": "https://example.com/level5.png",
|
||||
"prizeExterior": "崭新出厂",
|
||||
"prizeValue": "2200.00"
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"symbol": "A",
|
||||
"hit": false,
|
||||
"boxRecordId": 123457,
|
||||
"prizeName": "安慰饰品",
|
||||
"prizeImage": "https://example.com/consolation.png",
|
||||
"prizeGrade": 1,
|
||||
"prizeLevelImg": "https://example.com/level1.png",
|
||||
"prizeExterior": "崭新出厂",
|
||||
"prizeValue": "0.01"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应字段说明
|
||||
|
||||
**游戏配置字段**(原 `/game_config` 接口数据,现已合并):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `outerSymbols` | Array\<String\> | 外圈 24 格符号固定布局(`A`/`B`/`C`/`D`)<br>例:`["A","D","C","D",...]` |
|
||||
| `bets` | Array\<Integer\> | 10 档下注额 |
|
||||
| `myPoolType` | String | 当前用户所属决策池<br>`real`:真实池(普通02/主播01/招商04/卡商05,参与落点与 RTP)<br>`display`:展示(机器人03/白号06,可下注/返奖但不入真实池)<br>`null`:不可参与(如超管99) |
|
||||
| `rewardMatrix` | Array | 奖励矩阵(120 条,10 档 × 3 色 × 4 符号)<br>每条含 `betAmount`/`color`/`symbol`/`multiplier` 及饰品信息(名称/图片/品质图片/磨损度/价格) |
|
||||
|
||||
**当局状态字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `roundId` | Long | 回合 ID |
|
||||
| `periodNo` | Long | 全局唯一期号 |
|
||||
| `serverTime` | Long | **服务器当前时间**(秒级时间戳)<br>用于和其他时间点对比算倒计时 |
|
||||
| `startTime` | Long | 本局开始时间 |
|
||||
| `bettedTime` | Long | **封盘时间点** = startTime + 下注时间<br>到这个时刻后不能再下注 |
|
||||
| `spinTime` | Long | **开奖时间点** = bettedTime + 封盘时间<br>到这个时刻后开始播转盘动画 |
|
||||
| `endTime` | Long | **本局结束时间** = spinTime + 开奖动画时间<br>动画播完、下一局即将开始 |
|
||||
| `status` | String | `betting`:投注期<br>`spined`:封盘/开奖中 |
|
||||
| `innerColors` | Array\<String\> | **本轮内圈 24 色实际排列**(`gold`/`red`/`purple`)<br>例:`["gold","red","purple",...]` 用于渲染盘面 |
|
||||
| `resultIndex` | Integer | 开奖格子索引(0-23)<br>未开奖时为 `null` |
|
||||
| `resultColor` | String | 开奖颜色 `gold`/`red`/`purple`<br>未开奖时为 `null` |
|
||||
| `resultSymbol` | String | 开奖符号 `A`/`B`/`C`/`D`<br>未开奖时为 `null` |
|
||||
| `totalWin` | String | 当前用户本轮总返奖(饰品价值合计) |
|
||||
| `userBets` | Array | **当前用户本轮下注汇总**(按颜色+符号聚合,见下表) |
|
||||
| `winResult` | Array | **当前用户本轮发奖明细**(每注一条,见下表) |
|
||||
|
||||
**userBets 结构**(我的下注汇总,按颜色+符号聚合):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `color` | String | 下注的颜色 |
|
||||
| `symbol` | String | 下注的符号 |
|
||||
| `count` | Integer | 下注次数 |
|
||||
| `totalBet` | String | 总下注额 |
|
||||
|
||||
**winResult 结构**(我的发奖明细,每注一条):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `color` | String | 下注的颜色 |
|
||||
| `symbol` | String | 下注的符号 |
|
||||
| `hit` | Boolean | 是否命中(`true`=中奖矩阵饰品,`false`=安慰饰品) |
|
||||
| `boxRecordId` | Long | **背包记录ID**(用于调用分解/出售接口) |
|
||||
| `prizeName` | String | 实际发放的饰品名称 |
|
||||
| `prizeImage` | String | 饰品图片 URL |
|
||||
| `prizeGrade` | Integer | 饰品品质等级 |
|
||||
| `prizeLevelImg` | String | 饰品品质图片 URL |
|
||||
| `prizeExterior` | String | 饰品磨损度 |
|
||||
| `prizeValue` | String | 饰品价格(命中=下注额×倍率;安慰=0.01) |
|
||||
|
||||
> 💡 **前端使用提示**:
|
||||
> - 未开奖时 `winResult` 为空
|
||||
> - 已开奖后,`winResult` 每注一条:`hit=true` 是中奖饰品,`hit=false` 是安慰饰品(0.01元)
|
||||
> - 使用 `boxRecordId` 调用分解/出售接口
|
||||
|
||||
---
|
||||
|
||||
### 4.4 我的下注记录
|
||||
|
||||
**`POST /api/wheel/history_bet_info`**
|
||||
|
||||
当前登录用户自己的全部下注记录(中奖+未中奖),分页。未中奖注单(`win = 0.01` 安慰奖)的饰品字段为 `null`。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
```json
|
||||
{
|
||||
"pageNum": 1,
|
||||
"pageSize": 10
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"total": 25,
|
||||
"rows": [
|
||||
{
|
||||
"periodNo": 2026090725038,
|
||||
"endTime": "2026-09-07 09:14:49",
|
||||
"userId": 666299,
|
||||
"nickName": "李四2",
|
||||
"avatar": "/profile/upload/xxx.png",
|
||||
"bet": "5.00",
|
||||
"betSymbol": "D",
|
||||
"win": "40.00",
|
||||
"betCount": 3,
|
||||
"prizeGrade": 1,
|
||||
"prizeName": "某某饰品",
|
||||
"prizeImage": "/profile/upload/xxx.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `total` | Long | 总中奖注数 |
|
||||
| `rows` | Array | 中奖注单列表 |
|
||||
| `rows[].periodNo` | Long | 完整期号 |
|
||||
| `rows[].endTime` | String | 回合结束时间(yyyy-MM-dd HH:mm:ss) |
|
||||
| `rows[].userId` | Long | 用户 ID |
|
||||
| `rows[].nickName` | String | 用户昵称(完整,不脱敏) |
|
||||
| `rows[].avatar` | String | 用户头像 |
|
||||
| `rows[].bet` | String | 单注下注额 |
|
||||
| `rows[].betSymbol` | String | 中奖符号 |
|
||||
| `rows[].win` | String | 中奖额 |
|
||||
| `rows[].betCount` | Integer | 该中奖符号下注次数 |
|
||||
| `rows[].prizeGrade` | Integer | 饰品品质等级(对应 tt_ornaments_level.id:1金/2红/3紫/4蓝) |
|
||||
| `rows[].prizeName` | String | 饰品名称 |
|
||||
| `rows[].prizeImage` | String | 饰品图片 |
|
||||
|
||||
---
|
||||
|
||||
### 4.5 全服中奖记录
|
||||
|
||||
**`POST /api/wheel/round_history_bet_info`**
|
||||
|
||||
全服中奖公示(只保留真正命中 `win > 0.01` 的注单,排除 0.01 安慰奖),分页。结构与 `history_bet_info` 完全一致,只是数据范围为全服所有用户。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
```json
|
||||
{
|
||||
"pageNum": 1,
|
||||
"pageSize": 30
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"total": 99,
|
||||
"rows": [
|
||||
{
|
||||
"periodNo": 2026090725038,
|
||||
"endTime": "2026-09-07 09:14:49",
|
||||
"userId": 666299,
|
||||
"nickName": "李四2",
|
||||
"avatar": "/profile/upload/xxx.png",
|
||||
"bet": "5.00",
|
||||
"betSymbol": "D",
|
||||
"win": "40.00",
|
||||
"betCount": 3,
|
||||
"prizeGrade": 1,
|
||||
"prizeName": "某某饰品",
|
||||
"prizeImage": "/profile/upload/xxx.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `total` | Long | 总中奖注数 |
|
||||
| `rows` | Array | 中奖注单列表 |
|
||||
| `rows[].periodNo` | Long | 完整期号 |
|
||||
| `rows[].endTime` | String | 回合结束时间 |
|
||||
| `rows[].userId` | Long | 中奖用户 ID |
|
||||
| `rows[].nickName` | String | 中奖用户昵称(完整,不脱敏) |
|
||||
| `rows[].avatar` | String | 中奖用户头像 |
|
||||
| `rows[].bet` | String | 单注下注额 |
|
||||
| `rows[].betSymbol` | String | 中奖符号 |
|
||||
| `rows[].win` | String | 中奖额 |
|
||||
| `rows[].betCount` | Integer | 该中奖符号下注次数 |
|
||||
| `rows[].prizeGrade` | Integer | 饰品品质等级(对应 tt_ornaments_level.id:1金/2红/3紫/4蓝) |
|
||||
| `rows[].prizeName` | String | 饰品名称 |
|
||||
| `rows[].prizeImage` | String | 饰品图片 |
|
||||
|
||||
---
|
||||
|
||||
### 4.6 历史开奖结果
|
||||
|
||||
**`POST /api/wheel/round_results`**
|
||||
|
||||
获取每一轮的开奖符号(用于展示历史开奖记录,如右侧滚动的历史中奖列表)。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
```json
|
||||
{
|
||||
"pageNum": 1,
|
||||
"pageSize": 20
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `pageNum` | Integer | 否 | 页码(默认 1) |
|
||||
| `pageSize` | Integer | 否 | 每页条数(默认 20) |
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"rows": [
|
||||
{
|
||||
"periodNo": 2025010100086,
|
||||
"date": "20250101",
|
||||
"roundId": 86,
|
||||
"resultIndex": 5,
|
||||
"resultColor": "gold",
|
||||
"resultSymbol": "D",
|
||||
"endTime": "2025-01-01 12:15:39"
|
||||
},
|
||||
{
|
||||
"periodNo": 2025010100085,
|
||||
"date": "20250101",
|
||||
"roundId": 85,
|
||||
"resultIndex": 12,
|
||||
"resultColor": "red",
|
||||
"resultSymbol": "A",
|
||||
"endTime": "2025-01-01 12:15:00"
|
||||
}
|
||||
],
|
||||
"total": 2156
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `rows` | Array | 历史开奖记录列表 |
|
||||
| `rows[].periodNo` | Long | **全局唯一期号**(13位,前8位=日期yyyyMMdd,后5位=局号) |
|
||||
| `rows[].date` | String | **日期**(yyyyMMdd,从 periodNo 拆分而来) |
|
||||
| `rows[].roundId` | Long | **当天局号**(从 periodNo 拆分而来) |
|
||||
| `rows[].resultIndex` | Integer | 开奖格子索引(0-23) |
|
||||
| `rows[].resultColor` | String | 开奖颜色(gold/red/purple) |
|
||||
| `rows[].resultSymbol` | String | 开奖符号(A/B/C/D) |
|
||||
| `rows[].endTime` | String | 本局结束时间(yyyy-MM-dd HH:mm:ss) |
|
||||
| `total` | Long | 总记录数 |
|
||||
|
||||
#### 前端展示建议
|
||||
|
||||
```javascript
|
||||
// 展示期号
|
||||
function formatPeriodNo(periodNo) {
|
||||
const str = String(periodNo).padStart(13, '0');
|
||||
const year = str.slice(0, 4);
|
||||
const month = str.slice(4, 6);
|
||||
const day = str.slice(6, 8);
|
||||
const round = parseInt(str.slice(8)); // 去前导零
|
||||
return `${year}-${month}-${day}-${round}`;
|
||||
}
|
||||
|
||||
// 渲染列表
|
||||
data.rows.forEach(item => {
|
||||
// 期号:2025-01-01-86
|
||||
const periodText = formatPeriodNo(item.periodNo);
|
||||
|
||||
// 开奖结果:金色箱子 × 15
|
||||
const symbolIcon = getSymbolIcon(item.resultColor, item.resultSymbol);
|
||||
|
||||
renderHistoryItem(periodText, symbolIcon);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、时间轴使用指南
|
||||
|
||||
**核心**:后端返回的是**具体时间点**(startTime/bettedTime/spinTime/endTime),前端根据这些时间点和 `serverTime` 做倒计时和阶段切换。
|
||||
|
||||
### 一局的完整时间轴
|
||||
|
||||
```
|
||||
startTime (本局创建)
|
||||
│
|
||||
├─ 【下注期】serverTime < bettedTime
|
||||
│ 前端显示:倒计时 "距封盘 X 秒",接受下注
|
||||
│ 倒计时 = bettedTime - serverTime
|
||||
│
|
||||
├─ bettedTime (封盘时刻)
|
||||
│
|
||||
├─ 【封盘期】bettedTime ≤ serverTime < spinTime
|
||||
│ 前端显示:"封盘中",倒计时 "X 秒后开奖"
|
||||
│ 倒计时 = spinTime - serverTime
|
||||
│
|
||||
├─ spinTime (开奖时刻)
|
||||
│
|
||||
├─ 【开奖中】spinTime ≤ serverTime < endTime
|
||||
│ 前端显示:播转盘旋转动画
|
||||
│ 指针停在 resultIndex 格,显示 resultColor+resultSymbol
|
||||
│ 动画时长 = endTime - spinTime(由后台"开奖动画时间"配置)
|
||||
│
|
||||
├─ endTime (本局结束)
|
||||
│
|
||||
└─ 【等待下一局】serverTime ≥ endTime
|
||||
前端显示:倒计时 "下一局 X 秒后开始"
|
||||
继续轮询,拿到新 roundId 进入新一轮
|
||||
```
|
||||
|
||||
### 前端倒计时计算公式
|
||||
|
||||
| 阶段 | 判断条件 | 倒计时公式 | 显示文案 |
|
||||
|------|---------|-----------|---------|
|
||||
| **下注期** | `serverTime < bettedTime` | `bettedTime - serverTime` | "距封盘 X 秒" |
|
||||
| **封盘期** | `bettedTime ≤ serverTime < spinTime` | `spinTime - serverTime` | "X 秒后开奖" |
|
||||
| **开奖中** | `spinTime ≤ serverTime < endTime` | 播动画 | "转盘旋转中" |
|
||||
| **结果展示** | `serverTime ≥ endTime` | `下一局startTime - serverTime` | "下一局 X 秒后" |
|
||||
|
||||
### 关键提醒
|
||||
|
||||
1. **后端不返回时长配置**(下注时间/封盘时间/动画时间),只返回时间点。前端不需要自己算,直接用时间点差值。
|
||||
2. **时间点会随后台配置变化**:运营改"下注时间"从 30→50 秒,新局的 `bettedTime` 就会自动延后,前端倒计时自动变长。
|
||||
3. **开奖动画时长** = `endTime - spinTime`,这个窗口内播动画 + 展示结果。
|
||||
|
||||
---
|
||||
|
||||
## 六、前端对接要点
|
||||
|
||||
### 6.1 进入游戏页
|
||||
|
||||
```javascript
|
||||
// 只需轮询 /last_round_info,一次拿到配置+状态
|
||||
setInterval(async () => {
|
||||
const round = await getLastRoundInfo();
|
||||
|
||||
// 首次进入:用配置数据渲染静态元素
|
||||
if (firstTime) {
|
||||
renderWheel(round.outerSymbols);
|
||||
renderBetPanel(round.bets, round.rewardMatrix);
|
||||
firstTime = false;
|
||||
}
|
||||
|
||||
// 每次轮询:更新动态状态
|
||||
updateRoundState(round);
|
||||
}, 2000);
|
||||
```
|
||||
```
|
||||
|
||||
### 6.2 轮询当前局状态
|
||||
|
||||
```javascript
|
||||
// 每 1-2 秒调用一次
|
||||
setInterval(async () => {
|
||||
const round = await getLastRoundInfo();
|
||||
|
||||
// 根据 status 和时间点切换 UI
|
||||
if (round.status === 'betting') {
|
||||
if (round.serverTime < round.bettedTime) {
|
||||
// 下注期
|
||||
showCountdown('距封盘', round.bettedTime - round.serverTime);
|
||||
enableBetting(true);
|
||||
} else {
|
||||
// 封盘期(status 可能还未切到 spined)
|
||||
showCountdown('即将开奖', round.spinTime - round.serverTime);
|
||||
enableBetting(false);
|
||||
}
|
||||
} else if (round.status === 'spined') {
|
||||
// 已开奖
|
||||
if (round.resultIndex !== null) {
|
||||
// 播转盘动画,指针停在 resultIndex
|
||||
playSpinAnimation(round.resultIndex, round.innerColors);
|
||||
// 展示发奖明细(winResult 每注一条,hit 区分中奖/安慰)
|
||||
showWinResult(round.winResult, round.totalWin);
|
||||
}
|
||||
// 倒计时下一局
|
||||
if (round.serverTime >= round.endTime) {
|
||||
showCountdown('下一局', estimateNextRound());
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
```
|
||||
|
||||
### 6.3 下注
|
||||
|
||||
```javascript
|
||||
async function doBet(color, symbol, bet) {
|
||||
try {
|
||||
const res = await api.post('/api/wheel/dobet', { color, symbol, bet });
|
||||
if (res.code === 200) {
|
||||
// 扣款成功,更新余额
|
||||
updateBalance(res.data.balance, res.data.credits);
|
||||
// Toast 提示
|
||||
showToast('下注成功');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code === 702) {
|
||||
showToast('余额不足,请充值');
|
||||
} else if (err.code === 500) {
|
||||
showToast(err.msg); // "游戏不在投注期" / "每轮最多50注"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 展示饰品信息
|
||||
|
||||
```javascript
|
||||
// winResult 每注一条,hit 区分中奖/安慰
|
||||
round.winResult.forEach(item => {
|
||||
if (item.hit) {
|
||||
// 中奖,展示矩阵饰品
|
||||
showPrize({
|
||||
name: item.prizeName,
|
||||
image: item.prizeImage,
|
||||
levelImg: item.prizeLevelImg,
|
||||
exterior: item.prizeExterior,
|
||||
value: item.prizeValue,
|
||||
boxRecordId: item.boxRecordId
|
||||
});
|
||||
} else {
|
||||
// 未中,展示安慰饰品
|
||||
showConsolation(item.color, item.symbol);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 6.5 渲染盘面(innerColors)
|
||||
|
||||
```javascript
|
||||
// innerColors 是 24 个元素的数组,已经是 gold/red/purple
|
||||
// 例:["gold","red","purple",...]
|
||||
function renderWheel(innerColors, outerSymbols) {
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const color = innerColors[i]; // 'gold' / 'red' / 'purple'
|
||||
const symbol = outerSymbols[i]; // 'A' / 'B' / 'C' / 'D'
|
||||
|
||||
// 渲染第 i 格:内圈色 + 外圈符号
|
||||
renderCell(i, color, symbol);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.6 注意事项
|
||||
|
||||
| 注意项 | 说明 |
|
||||
|-------|------|
|
||||
| **单接口轮询** | 只需轮询 `/last_round_info`,配置数据已合并,无需单独调 `/game_config` |
|
||||
| **不要自动重发 dobet** | 下注接口**非幂等**,失败时提示玩家手动重试 |
|
||||
| **轮询间隔** | 建议 1-2 秒,不要过于频繁 |
|
||||
| **时区** | 所有时间戳为东八区(UTC+8) |
|
||||
| **饰品已入背包** | 中奖后饰品直接进背包,无需"保留/分解"抉择<br>要兑现走通用背包分解接口 |
|
||||
| **维护模式** | 后台"游戏关闭"开关打开后:<br>① 新局无法下注(`dobet` 返回 500 维护提示)<br>② 轮询接口返回 `isMaintenance: true`,前端应弹窗并跳转<br>③ 进行中回合不强制结算,玩家可继续看结果 |
|
||||
|
||||
---
|
||||
|
||||
+1425
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,516 @@
|
||||
# Olympus 电子游戏 — Python 端结构文档
|
||||
|
||||
> match-3 消除类电子游戏(皇冠/宝石主题)的**规则核心 + 离线回合生成器**。
|
||||
> Python 端负责:可执行的规则规范(引擎)、回合数据预生成、落库。
|
||||
> 在线播放接口、结算发奖由后续的 Java 服务端消费本模块产出的 `round/step` 数据实现。
|
||||
|
||||
---
|
||||
|
||||
## 一、目录结构
|
||||
|
||||
```
|
||||
olympus-py/
|
||||
├── olympus/ 规则核心包(纯逻辑,无第三方依赖)
|
||||
│ ├── __init__.py 包导出:引擎/模型/关键配置
|
||||
│ ├── config.py 全部规则配置 + 3 个查询函数
|
||||
│ ├── engine.py OlympusEngine 结算状态机(规则的可执行规范)
|
||||
│ └── models.py 不可变数据模型(Bomb/Elimination/Cascade/Spin/Session)
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ └── test_engine.py 25 项单元测试(规则回归)
|
||||
├── olympus_simulator.py 回合生成器核心(网格/级联/炸弹/免费旋转/落桶/截断)
|
||||
├── generate_rounds.py 生成入口(--per-box 每箱总回合 / box_id 从1顺序)
|
||||
├── olympus_db.py 建表 + 批量写库(连接参数走环境变量)
|
||||
├── schema.sql 全量 DDL(12 张表)+ 饰品桶种子数据
|
||||
└── pyproject.toml 包元信息(name=olympus, py>=3.9)
|
||||
```
|
||||
|
||||
> 辅助脚本:`_reset_boxes.py`(重置——清空 box/box_ornament/round/step/box_template)、`_create_reward_log.py`(建奖池调整日志表 game_olympus_reward_log)。
|
||||
|
||||
分两层:
|
||||
- **规则核心 `olympus/`**:定义"游戏应该怎么算",被引擎和生成器共同依赖,是唯一事实来源。
|
||||
- **生成/落库工具(根目录)**:调用规则核心批量产出回合数据并写入 MySQL。
|
||||
|
||||
---
|
||||
|
||||
## 二、核心游戏规则总览
|
||||
|
||||
| 规则 | 值 / 说明 |
|
||||
|---|---|
|
||||
| 网格 | 5 行 × 6 列 = 30 格 |
|
||||
| 中奖门槛 | 屏幕任意位置同符号 **≥8 个**即赔付(pay-anywhere) |
|
||||
| 普通符号 | 9 种:皇冠/沙漏/环/杯赛/红/紫/黄/绿/蓝宝石,各有 **8-9 / 10-11 / 12-30** 三档区间赔率(12 个及以上至全屏 30 格归最高档) |
|
||||
| SCATTER | 4/5/6 个赔 3x/5x/100x,且 **≥4 个触发免费旋转** |
|
||||
| 免费旋转 | 首次赠送 **15 次**;FS 中 **≥3 个 SCATTER 重触发 +5 次**(4/5/6 个同时赔 3x/5x/100x 并入本旋赢额;恰好3个只+5不赔),上限 30,**到上限后不再落 3+ 个 SCATTER**(否则会写出"extraSpins=5 但加不上旋"的 retrigger_info) |
|
||||
| 炸弹(乘数符号) | 值 2x~1000x,分 5 种品质色;**只在免费旋转内出现**(normal/ante 的基础旋、含进 FS 的触发盘一律不出) |
|
||||
| 消除→引爆 | 一次旋转内多波消除,**全部消完后末帧统一引爆炸弹** |
|
||||
| 单局封顶 | 累计赢奖 **≤ 1000 倍**基础下注,超出截断 |
|
||||
|
||||
---
|
||||
|
||||
## 三、三种游戏模式(互斥)
|
||||
|
||||
| 模式 | code | 扣费倍率 | 实际扣费档位 | 底注效果 | 可购买 FS | 保证进 FS |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 普通旋转 | `normal` | 1× | 1,3,5,10,20,30,50,100,200,300 | ✗ | ✓ | ✗ |
|
||||
| 底注 | `ante` | 2× | 2,6,10,20,40,60,100,200,400,600 | ✓ | ✗ | ✗ |
|
||||
| 购买免费旋转 | `buy_fs` | 50× | 50,150,250,500,1000,1500,2500,5000,10000,15000 | ✗ | ✗ | ✓ |
|
||||
|
||||
**互斥关系**(双向):底注开启 → 禁止购买免费旋转;购买免费旋转 → 强制关闭底注效果。三种模式基础注档位完全一致(均 10 档)。
|
||||
|
||||
- `normal` vs `ante`:ante 花双倍钱,换 **FS 触发概率翻倍**(0.5%→1%)+ SCATTER 出现率更高 + 桶顶更高(150x→200x)。
|
||||
- `buy_fs`:花 50 倍直接进 15 次免费旋转,是累计器滚雪球的主要场景。**首盘固定 4 个 SCATTER**(直接赔 3x),且该盘不出炸弹(炸弹只在免费旋转内出现,触发盘炸弹本来也不带入累计器)。
|
||||
- **normal/ante 也能进 FS**:基础旋摇出 4~6 个 SCATTER 就免费进 15 旋,进 FS 后与 buy_fs **完全同一套规则**(炸弹、累计器、每旋保底消除),见第三点五 / 第四节。
|
||||
|
||||
---
|
||||
|
||||
## 三点五、基础旋为什么没有炸弹(normal/ante)
|
||||
|
||||
> 规则:**炸弹只存在于免费旋转内**。`_spin()` 里 `if not is_free_spin: allow_bomb = False` —— 基础旋(含进 FS 的触发盘、含补桶植入的大奖旋)整盘不会有 `M`。
|
||||
|
||||
随之而来的物理限制:没有炸弹,基础旋最多只能凑到 `A12(50) + B10(10) ≈ 60x`(自然多组 12 连到 75x 已属极罕见),**100x / 150x / 200x 这些桶基础旋根本赔不出来**。所以:
|
||||
|
||||
| 目标倍率 | 谁来装这个桶 |
|
||||
|---|---|
|
||||
| < 80x(`FS_ONLY_TARGET_MUL`) | 基础旋(`--alloc config` 补桶时用 `_plan_base_only` 植基础组合) |
|
||||
| ≥ 80x(100x/150x/200x 桶) | **自然触发 FS 的回合**:`generate_round(box, bias≥80)` 直接生成"4~6 S 触发盘 + 15 旋 FS",FS 预算 `= 桶目标 − 触发盘赢额` |
|
||||
|
||||
这正是这两个模式里"能进免费旋转"的现实来源:玩家在后台配比里抽中 100x/150x(/200x) 桶时,看到的就是一次免费旋转(box1:桶 12/13 的池子 100% 是 FS 回合)。
|
||||
|
||||
实测(2026-09 用当前生成器重跑全部 30 箱之后;全库 39,431 回合 / 358,234 步):
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|---|---|
|
||||
| 盘面 | 5×6、30 格全满、只有 A~I/S/M —— **空白格 0、非法格 0** |
|
||||
| 掉落复刻 | 每一步都能用「上一波盘面 − 上一波新增连线 → `drop_data` 下落 → `fill_data` 补位」**精确**复刻(不符 0 条) |
|
||||
| 金额 | 连线 `base_score` = 赔付表、单旋赢额 = 基础分 × max(1, 累计+本旋炸弹) + scatter、累计器、`spin_win` 连续 —— 全对(只有 2 步在 1000x 封顶截断局里 `accumulator_after` 记的是未并入值,该字段已下发为接口 `accumulator`) |
|
||||
| 免费旋转多样性 | 13,542 个 FS 回合,**整局盘面完全重复 = 0** |
|
||||
| 基础旋炸弹 | normal/ante 基础旋炸弹格 **0**;触发盘 S 数:normal 519 局 / ante 818 局都是 4~6,买 FS 12,205 局固定 4 |
|
||||
| 100x+ 桶 | normal/ante 的桶 12/13/14 池子 200/200/200 条**全部**是 FS 回合(每箱 49~55 / 76~88 个 FS 回合) |
|
||||
| RTP | 30 个箱**后台配比反推 = 池内实际口径 = 79.99%~80.03%**(见第六点五;重算后不再有 ±1.7pt 偏差) |
|
||||
| 池子覆盖率 | 池内回合数 / 配比 count 最小 **1.36x**(≥1 → 一个 cycle 内不会重复发) |
|
||||
| 抽回合模拟 | `simulate_cycle.py` 复刻 Java 链路(含 payoutCap/水位/抽干按模板重填)各 3~4 个 cycle 都收敛到 **80.0%** |
|
||||
|
||||
---
|
||||
|
||||
## 四、炸弹累计器(关键机制)
|
||||
|
||||
> **累计器只存在于免费旋转模式**,基础旋转没有累计器。三种模式只要进入 FS(normal/ante 摇出 4+S 或 buy_fs 购买)均启用累计器,与模式无关。
|
||||
|
||||
| 场景 | 炸弹行为 | 累计器 |
|
||||
|---|---|---|
|
||||
| 基础旋转(normal/ante 未进 FS) | **整盘不出炸弹**(`allow_bomb=False`) | 不存在(`multiplier_after` 不变) |
|
||||
| 免费旋转(三模式一致:≥4 S 进入 / buy_fs 购买) | 每屏最多 1 颗(`FS_SCREEN_BOMB_PROB=0.08` 每空格);有消除时并入累计器,跨旋传递 | **从 0 起累**,永不归零直到 FS 结束 |
|
||||
|
||||
**计算公式**(`engine.end_spin`):
|
||||
- 基础旋转:`本旋赢额 = 基础分 × max(1, 本旋炸弹和)`
|
||||
- 免费旋转:`本旋赢额 = 基础分 × max(1, 累计值 + 本旋新炸弹)`,之后 `累计值 += 本旋炸弹和`
|
||||
(**累计倍数作用到每一次消除** —— 0.4×2 + 0.5×4 = 2.8 这种滚雪球,这是这个玩法的核心爽点)
|
||||
|
||||
三条性质:① 免费旋转内累计器只涨不跌;② 无消除的旋转炸弹作废、累计值保持;③ 触发盘(进 FS 那一旋)的炸弹**不带入** FS 累计器。
|
||||
|
||||
> **推论(决定了"每局能演几次消除")**:n 次"消除+炸弹"的累计花费 ≈ `0.125 × 平均炸弹值 × n²` 倍底注,
|
||||
> 所以**炸弹越小,买得起的消除次数越多**(单个球值 b、n 次连爆 ≈ 0.125·b·n² ≤ 该局 FS 预算)。
|
||||
> 实测(买FS 售价 50 倍、RTP 80%、桶 7~17):
|
||||
>
|
||||
> | 桶(抽中率) | FS 预算 | 每局消除旋数 | 每局炸弹并入 | 局末累计中位 |
|
||||
> |---|---|---|---|---|
|
||||
> | 10x(35.6%) | 7 | 5.3 旋 | 3.92 次 | 15 |
|
||||
> | 16x(23.4%) | 13 | 5.2 旋 | 3.97 次 | 16 |
|
||||
> | 25x(16.1%) | 22 | 5.3 旋 | 4.21 次 | 20 |
|
||||
> | 40x(9.2%) | 37 | 4.9 旋 | 3.62 次 | 16 |
|
||||
> | 100x(3.8%) | 97 | 4.7 旋 | 3.44 次 | 17 |
|
||||
> | 150x~1000x(4.5%) | 147+ | 3~6 旋 | 2~5 次 | 26~258 |
|
||||
>
|
||||
> **炸弹值按档位配**(`FS_SMALL_BOMB_MAX=8` / `FS_BIG_BOMB_TARGET=100`):低倍档(<100x)只出 2~8 的小球
|
||||
> ——大球一合并,后面每次消除都被放大几十倍,低倍档预算立刻见底;高倍档(≥100x)用全量 2~1000,
|
||||
> 所以"大炸弹"照样存在,只是它出现的那些局自然落在高倍桶。
|
||||
> 每屏最多 1 颗(`FS_MAX_BOMBS_PER_SPIN`),玩家才看得出"消一次爆一颗、倍数一颗颗涨"。
|
||||
> > 2026-09 试过一版"按桶分档"(低倍局只用 2~4、自然路径放开到 1000):低倍桶消除 3.65→4.29 次/局,
|
||||
> > 但高倍桶出现大球后**消除次数反而掉**(1000x 桶 13→8.7 次/局)—— 一局赔付 ≈ 系数×k²、桶目标固定,
|
||||
> > 大球把后面每次消除都放大,同样的钱只够买更少的消除。用户定调"消除多优先",故已回退;
|
||||
> > 实测数据与折中做法(如"每局最多一颗大球""大球只给 300x+")留在 `docs/玫瑰女郎炸弹倍数分档方案.md`。
|
||||
|
||||
**推论(改生成器时必须知道)**:累计器只在"本旋有消除"时才吃炸弹,而炸弹均值 ≈25 倍(`w = 1/√v` 权重)→ **一次带炸弹的消除就把累计倍数推到几十上百**,此后任何一次小消除都被放大成 7.5~50。所以"某局 FS 能容纳多少次消除"完全由该局的原始分预算决定:
|
||||
|
||||
| buy_fs 桶 | 触发盘白送 | FS 可用预算 | 保底上限 |
|
||||
|---|---|---|---|
|
||||
| 4x(3.00~5.00) | 3.00 | ~1.0 | 约 2~4 次消除 |
|
||||
| 6.5x(5.00~8.00) | 3.00 | ~3.5 | 约 8~14 次 |
|
||||
| 10x(8.00~12.00) | 3.00 | ~7.0 | 每旋都能消 + 连消 |
|
||||
|
||||
> **buy_fs 的可用桶已改为 10x~1000x(桶 7~17)**:4x/6.5x 桶整局预算只有 1~3.5 倍底注,
|
||||
> 演不出"15 旋里十来旋都在消 + 炸弹累计",玩家看到的一直是空转动画。
|
||||
> 改动点:Java `OlympusModeType.getOrnamentRange()` 的 `BUY_FS` → `{7,17}`;
|
||||
> py `MODE_ORNAMENT_IDS['buy_fs']` → `range(7,18)`;后台「一键重算」重配 count
|
||||
> (py 侧可跑 `python _recalc_boxes.py --mode buy_fs` 复刻同一算法)。
|
||||
> 实测观感(box21,玩家加权):每局 **4.5 旋**有消除、**1.16 次**炸弹并入累计器、低倍局累计倍数中位 **9**(改前分别约 3.2 旋 / 0.4 次 / 0)。
|
||||
> 注意:新配比下 buy_fs 的实际 RTP 会落在 **~81.5%**(10x 桶占 69%,该桶实际均值 10.97 > 名义 10.0);要压回 80% 就把这 10 个箱的 rtp 设成 0.785 再重算。
|
||||
> **(已修正,见第六节"配比口径")** 现在配比不再按名义 expect_mul 算,而是按每个箱子池内 `AVG(actual_mul)` 算,
|
||||
> 「设 80% → 实际就赔 80%」,不再需要手改 rtp 这种补偿手段。
|
||||
|
||||
---
|
||||
|
||||
## 四点五、免费旋转"每旋保底消除"(三模式统一)
|
||||
|
||||
> 动机:旧实现 30 格里自然凑出 8 连的概率只有 **17%**,再叠 45% 强制空转,导致 **86.8% 的 FS 旋完全空转**(一局 15 旋里平均只有 2.4 旋有消除),观感"每轮都不消"。
|
||||
> 现在 normal/ante/buy_fs **只要进了 FS 走的就是同一条代码路径**(`_free_spin_session`),区别只在"这一局值多少钱"。
|
||||
|
||||
| 参数 | 值 | 作用 |
|
||||
|---|---|---|
|
||||
| `FS_SPIN_WIN_PROB` | 0.60 | 不限额时每旋"至少消一波"的基础概率 |
|
||||
| `FS_CASCADE_CONTINUE_PROB` | 0.10 | 消完一波后继续连消的概率(越低,同样预算能铺到越多旋) |
|
||||
| `FS_CASCADE_MAX` | 5 | 单旋最大消除波数 |
|
||||
| `FS_GUARANTEE_COMBOS` | 8 连 I/H/G | 保底用最小档消除(0.25/0.4/0.5 倍底注),不覆盖已有 S/M 格 |
|
||||
| `FS_GUARANTEE_COST` | 0.45 | 每多一次"保底消除+炸弹累计"的边际赔付(≈0.45×k,见下) |
|
||||
| `FS_DRY_FORCE_PROB` | {5:0.5, 6:0.85} | 连续空转到第 5/6 旋时"按概率补保底"(软兜底,避免长空尾) |
|
||||
| `FS_DRY_FORCE_HARD` | 8 | 连续空转到这个旋数无条件补 |
|
||||
| `FS_FLOOR_SPEND` | ≈0.28 | 保底预算的换算口径(0.25 / (1−0.10)) |
|
||||
| `FS_FLOOR_BUDGET_CAP` | ≈4.17 | 一局的保底预算上限(15 旋 × 0.28) |
|
||||
|
||||
**预算规则**(`generate_round`):
|
||||
|
||||
| 场景 | 预算 | 说明 |
|
||||
|---|---|---|
|
||||
| buy_fs / 高倍桶补桶(`bias` = 桶目标) | `保底预算 = min(上限, 目标 − 触发盘赢额)`,差额给"一旋大奖"(`_force_win`) | 保证每旋在消又不打穿低倍桶 |
|
||||
| buy_fs 自然阶段(bias=None) | 不限额 → 每旋 60% 保底 | 15 旋全在消(累计器会把整局抬到几百倍,所以只出现在高倍桶) |
|
||||
| **normal/ante 自然触发 FS** | 固定 `FS_FLOOR_BUDGET_CAP`(无大奖旋) | 免费摇出来的没有价格可拆,保底预算 + 炸弹累计自然结算 |
|
||||
|
||||
- **保底消除"配额 + 随机间隔"(2026-09 修正)**:一局能买几次消除由预算反推
|
||||
`k = max(1, √(保底预算 / FS_GUARANTEE_COST))`;每次消除(自然或保底)后**重抽下一次的位置**
|
||||
(`_fs_next_target`:间隔 = `round(剩余旋数/剩余配额 + δ)`,δ∈{-2..2} 权重 {1,2,3,2,1},
|
||||
均值 0 → 整局消除次数不变、位置随机);空转太久再按 `FS_DRY_FORCE_PROB` 软兜底(第 5/6 旋
|
||||
0.5/0.85,第 8 旋必补),防止配额提前用完留下长空尾。
|
||||
> **为什么要改成随机**:先是"等距铺"(`due += k/总局数`,到点必消),虽然没有长空尾,但间隔被
|
||||
> 压在 1~5 旋、**87% 的局"最长连空正好 4 旋"**,玩家几局就看穿"到第 4 旋肯定消"。随机化后
|
||||
> 相邻两次消除的间隔散成 1~7 旋(35/20/14/11/8/8/3),每局最长连空 1~8 旋散开(单值最高 41%);
|
||||
> 硬闸版本(到第 N 旋必消)会留下新的固定天花板(硬顶 6 时 42% 的局正好卡在 6),所以兜底必须是
|
||||
> **概率递增**而不是硬上限。
|
||||
> **为什么要改(上一版)**:旧写法 `p = min(1, 剩余预算 / (FS_FLOOR_SPEND × 剩余旋数))` 里扣预算
|
||||
> 扣的是**已被累计倍数放大后的赢额**(`win = 基础分 × max(1, 累计+本旋炸弹)`),累计器一涨,几次
|
||||
> 保底就把预算烧穿 → `p = 0` → 后半局再也不会消。全库实测(`python _regen.py fsdry`):买 FS
|
||||
> **87.9%** 的局出现 ≥4 旋连空、**51.7%** 的局一直空转到结束、单局最长 **29 旋**;natural 触发
|
||||
> FS 更高(96~97%)。修完同一份预算下:买 FS 有消除 **4.2 → 4.8 旋/局**、最长连空 ≥7 旋的局
|
||||
> **61% → 2%**、单局最长 29 → 11 旋、平均最长连空 9.6 → 4.5 旋。
|
||||
- **物理上限(调生成器前必须知道)**:k 次消除的整局赔付 ≈ `FS_GUARANTEE_COST × k²`(每次消除都把炸弹并进
|
||||
累计器,倍数作用到之后每一次消除 → 平方增长)。所以 10x 桶(预算≈7)只买得起 **~4 次消除**;
|
||||
想再多消就得靠"少出炸弹",但那样赔付地板会抬高、这些回合不再落在低倍桶(实测 10x 桶直接填不满)。
|
||||
- **炸弹值按档位配**(`spin_bomb_max`):目标 < `FS_BIG_BOMB_TARGET`(100) 用 2~8 的小球(预算买得起多次"消除+爆"),目标 ≥100 用全量 2~1000。
|
||||
> 想给低倍桶更多消除**不能靠"把球做小"**:球小确实省钱,但低倍桶变成"小球多消"后赔付地板抬高、
|
||||
> 命中率下降(实测 10x 桶只填到 117/292,且高倍桶一旦出大球消除次数反掉)—— 详见 `docs/玫瑰女郎炸弹倍数分档方案.md`。
|
||||
- **保底消除种在"刚落下/刚补位"的格子上**(`_force_small_win(grid, prefer=补位格)`):这些格子本来就有 `drop_data`/`fill_data` 记录,前端按动画数据落地就能与 `grid_state` 完全一致;`drop_data`/`fill_data` 里的 `symbol` 也统一按**最终盘面**回填(`_spin` 里补位、保底之后统一同步一次)。
|
||||
> 改前的老数据里,保底消除会随机改写**没移动过**的格子 → 那类格子没有任何动画记录,前端只能靠 `grid_state` 兜底(不会出现空格,但符号要等这一步渲染完才对齐)。全库体检:`grid_state` 空白格 0、`fill_data` 漏格 0、`drop_data` 与重力不符 0、drop/fill 符号与盘面不符 0;老数据剩 10,603 格属于上述"原地改写"。
|
||||
|
||||
**验收口径**:`python _regen.py fsdry [box_id]` 打印每模式/每桶的"有消除旋数、最长连空分布、≥4 连空占比"
|
||||
(买 FS 的 ≥4 连空一列按后台配比加权 = 玩家实际遇到的概率),并列出最差局;单测
|
||||
`tests/test_simulator.py::TestFreeSpinFloorSpread` 锁住"平均最长连空 < 8、不出现十几旋死尾巴"。
|
||||
|
||||
实测(box1:natural 触发 FS 池内 49 个回合,`_regen.py fsstats 1`):每局 **5.1~5.5 旋**有消除、**3.9~4.0 次**炸弹并入累计器、局末累计倍数中位 **15~16**,落桶与名义值基本贴合(桶 12 实测 115.3 vs 名义 100、桶 13 实测 147.8 vs 150)。
|
||||
买 FS(`_regen.py fsstats 21`,按配比加权):每局 **4.6 旋**有消除、**3.16 次**炸弹并入;连空体检 `_regen.py fsdry` 见四点五。
|
||||
|
||||
---
|
||||
|
||||
## 五、爆奖动画(小/中/大 3 档)
|
||||
|
||||
> 判定口径:**只在免费旋转里判档**(normal/ante 靠自然触发 FS、buy_fs 本身即 FS),
|
||||
> **按单旋赢额独立判档**,达标即在该旋末帧盖章,旋与旋互不影响,**同一档一局可多次触发**;
|
||||
> 基础旋(`spinNum=0`)永远是 `null`,不播大奖动画。
|
||||
|
||||
档位编码:`small < middle < big`(小 / 中 / 大)。
|
||||
|
||||
阈值表(单位:单旋赢额 ÷ 基础下注,配置于 `BIGWIN_TIERS`)——**两种情况两套阈值**:
|
||||
|
||||
| 情况 | 模式 | small | middle | big |
|
||||
|---|---|---|---|---|
|
||||
| ① 自然免费旋转 | `normal` / `ante` | ≥5 | ≥25 | ≥100 |
|
||||
| ② 购买免费旋转 | `buy_fs` | ≥20 | ≥60 | ≥200 |
|
||||
|
||||
为什么两套:normal/ante 的自然 FS 预算与旋数完全一致(整局约 80~130x),所以共用一套;
|
||||
buy_fs 花 50 倍买来的赢额分散在 15+ 旋里,单旋阈值整体下调(否则连"小"都很难出现)。
|
||||
|
||||
实测出现频率(2026-09 重跑后的池子,逐局统计):
|
||||
|
||||
| 情况 | small | middle | big |
|
||||
|---|---|---|---|
|
||||
| ① 自然 FS(normal 519 局 / ante 818 局) | 91.7% / 93.5% 的局出现(每局 2.3~2.5 次) | 49.3% / 48.8%(每局 0.6 次) | 31.4% / 39.0%(每局 0.3~0.4 次) |
|
||||
| ② 买 FS(12205 局) | 19.5% 的局出现(每局 0.47 次) | 6.3%(每局 0.13 次) | **1.5%**(每局 0.02 次) |
|
||||
|
||||
> 买 FS 的"小"只覆盖 19.5% 的局:它门槛是"单旋 ≥20 倍"(= 售价的 0.4 倍),而 10x/16x 桶整局才 10~20 倍,所以那些局不会有任何爆奖动画。
|
||||
> 想让"小"更常见就把 `BIGWIN_TIERS['buy_fs']` 的第一档调低(比如 10),改完重跑数据即可。
|
||||
|
||||
---
|
||||
|
||||
## 六、饰品桶(17 档)与匹配/发放分离
|
||||
|
||||
`ORNAMENTS` 定义 17 个倍率桶(0x ~ 1000x),`expect_mul` 严格 = `(range_low + range_high) / 2`,区间连续无缝。
|
||||
|
||||
**匹配 vs 发放分离**(顶桶特例):
|
||||
- 17 号桶(展示名 `1000x`)匹配区间 **630~1370**,中点/期望 = 1000;
|
||||
- 但实际发放**封顶 1000 倍**:回合理论总分落在 1000~1370 仍归此桶,发钱时截断到 1000 并标记 `win_capped`。
|
||||
- 即:**落桶用理论分(≤1370),发钱用截断值(≤1000)**,两层各司其职。
|
||||
|
||||
### 六点五、配比口径:按"池内实际会赔多少"算(2026-09 修正)
|
||||
|
||||
> 以前的配比(`count`)是按**名义** `expect_mul`(区间中点)反推的,于是「设 80% → 实际赔 81.8%」这种系统性偏差:
|
||||
> 桶内实际均值跟区间中点差很多(实测 **-18% ~ +11%**),桶 17 又受 1000x 封顶影响实际只有 **~835**(不是 1000)。
|
||||
|
||||
现在 Java `OlympusDistributionService` 与 py `_recalc_boxes.py` 都改成:
|
||||
|
||||
| 环节 | 口径 |
|
||||
|---|---|
|
||||
| 配比 `calcRounds` | 期望值取 **该箱池内 `AVG(actual_mul)`**(`loadPoolExpect(boxId)`;某桶池子里一条回合都没有时才回退名义值) |
|
||||
| 后台「目标RTP」列 | 同一个口径反推 = `Σ(count × 池内均值) / Σcount / cm` —— 也就是**这个箱子真实会赔多少** |
|
||||
| 抽回合的 payoutCap 过滤 | 仍用名义 `expect_mul`(`OlympusRoundPicker` 读 DB,偏保守,保留安全余量) |
|
||||
| 微调 | 从"只在最大的 3 个桶之间搬 1 个"改成**任意两个桶搬 1 个**(保持单调),否则池内均值口径下根本走不到目标,会差 0.5~1 个百分点 |
|
||||
|
||||
效果(30 个箱全部重算后实测):每个箱的期望赔付 **79.97% ~ 80.02%**(目标 0.8),`simulate_cycle.py` 整轮收敛 80.0%。
|
||||
注意:池子重新生成过之后要**再跑一次重算**(配比是跟着池子里的这批回合走的);重算会把 `used` 归零,
|
||||
Redis 的本轮账本要一起清(后台「一键重算」会清;py 侧 `_recalc_boxes.py` 只动库,跑完手动清,
|
||||
`ruoyi:olympus:cycle:{boxId}:{audience}`),否则「实时RTP」显示的是旧抽数+新抽数混在一起的值。
|
||||
|
||||
---
|
||||
|
||||
## 七、多身份爆率(audience)
|
||||
|
||||
参照现有宝箱的"普通/主播/机器人"爆率,olympus 同样支持多身份差异化 RTP。但实现方式不同:
|
||||
|
||||
| | 现有宝箱 | olympus |
|
||||
|---|---|---|
|
||||
| 爆率载体 | 每饰品配多列 odds(real/anchor/robot) | 每饰品桶的 `count`(按 audience 分套) |
|
||||
| 开奖方式 | 开箱实时按身份 roll 饰品 | 开局按身份桶分布抽一个**预生成回合** |
|
||||
|
||||
**核心:回合库存池(round/step)完全共享,仅“抽哪个桶”的概率按身份区分。** 四档身份(对齐 `tt_user`):
|
||||
|
||||
| audience | 对应身份 | 识别字段 |
|
||||
|---|---|---|
|
||||
| `common` | 普通用户(及招商/卡商/超管等) | user_type=02 及其他 |
|
||||
| `anchor` | 主播 | user_type=01 |
|
||||
| `robot` | 机器人 | user_type=03 |
|
||||
| `white` | 白号 | is_blank_account=1(**压过 user_type**) |
|
||||
|
||||
**身份解析优先级(Java 开局)**:白号最高优先——`is_blank_account==1 → white`(不管哪个 user_type);否则 01→anchor / 03→robot / 其余→common。
|
||||
|
||||
实现:
|
||||
- `game_olympus_box_ornament` 带 `audience` 列,唯一键 `(box_id, ornament_id, audience)`,每身份一套 `count`/`used` 分布;
|
||||
- 生成器写库时四档各写一套(首版相同,后台可差异化调各桶 count);
|
||||
- Java 开局时按优先级选 audience 分布抽回合(回合池共享,无需重生)。
|
||||
|
||||
**白号池(white)专项规则**(消耗隔离——白号不碰其他人奖池):
|
||||
- 独立有限库存:白号抽取只递减 `white` 自己的 `used`、抽干独立补桶,**永不消耗 common/anchor/robot 库存**(由 audience 维度天然隔离);
|
||||
- white 桶范围与 common 一致,默认分布相同,运营可单独调;
|
||||
- 水位隔离:白号 bet/win **不计入水位**(对齐 bullet);
|
||||
- 白号赢奖不进真实经济(提货虚假成功、不计充值/主播收益,与现有白号语义一致)。
|
||||
- 机器人暂不细分幸运/非幸运,后续视 2v2 需求再扩展。
|
||||
|
||||
---
|
||||
|
||||
## 八、模块 API 说明
|
||||
|
||||
### 8.1 `olympus/config.py` — 规则配置(唯一事实来源)
|
||||
|
||||
| 名称 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `SymbolRule` | dataclass | 符号赔付规则,`payout_for(count)` 按 8/10/12 档取倍率 |
|
||||
| `SYMBOL_RULES` | dict | 9 种普通符号赔付表 |
|
||||
| `SCATTER_PAYOUTS` | dict | `{6:100, 5:5, 4:3}` |
|
||||
| `SCATTER_FREE_SPIN_TRIGGER` / `SCATTER_RETRIGGER_TRIGGER` | int | 4 / 3 |
|
||||
| `FREE_SPINS_AWARDED` / `FREE_SPIN_RETRIGGER_COUNT` / `MAX_FREE_SPINS` | int | 15 / 5 / 60 |
|
||||
| `MULTIPLIER_VALUES` | frozenset | 16 个合法炸弹倍数 |
|
||||
| `MAX_WIN_MULTIPLIER` | Decimal | 1000(封顶) |
|
||||
| `GameMode` / `MODES` / `DEFAULT_MODE` | dataclass/dict | 三模式定义,`price_for(bet)` 算扣费 |
|
||||
| `ORNAMENTS` | list | 17 档饰品桶 |
|
||||
| `GRID_ROWS/COLS/SIZE` | int | 5 / 6 / 30 |
|
||||
| `SYMBOL_SHORT` / `SHORT_TO_SYMBOL` | dict | 符号↔短码(A~I, S, M)互转 |
|
||||
| `SYMBOL_WEIGHTS` / `BOMB_*` | - | 生成器随机权重、炸弹概率/品质 |
|
||||
| `BOMB_QUALITIES` / `bomb_quality(v)` | list/func | 炸弹 5 档品质,值→品质编码 |
|
||||
| `BASE_FS_TRIGGER_PROB` / `*_SCATTER` | dict | FS 触发与 SCATTER 散布分布 |
|
||||
| `MODE_ORNAMENT_IDS` | dict | 各模式可用桶范围 |
|
||||
| `BIGWIN_TIERS` / `bigwin_level(mode, mul)` | dict/func | 爆奖小/中/大 3 档阈值与判档(两种情况两套阈值) |
|
||||
|
||||
### 8.2 `olympus/models.py` — 数据模型(全部 frozen dataclass)
|
||||
|
||||
| 模型 | 关键字段 |
|
||||
|---|---|
|
||||
| `Bomb` | `bomb_id, value, position` |
|
||||
| `Elimination` | `symbol, count, positions, payout_multiplier` |
|
||||
| `CascadeResult` | `cascade_index, eliminations, bombs, base_win, accumulated_symbols` |
|
||||
| `SpinResult` | `spin_index, cascades, base_win, multiplier_before/after, bombs_added, effective_multiplier, total_win, accumulated_symbols, win_capped` |
|
||||
| `SessionState` | `next_spin_index, accumulated_multiplier, total_win, spins`;`reset()` |
|
||||
|
||||
### 8.3 `olympus/engine.py` — `OlympusEngine` 结算状态机
|
||||
|
||||
规则的**可执行规范**(一局 = 一次付费;一 spin 内可多次 cascade):
|
||||
|
||||
```python
|
||||
engine = OlympusEngine(bet=10, mode="normal") # 校验模式与档位
|
||||
engine.begin_spin(is_free_spin=False) # 开旋(FS 需传 True 才启用累计器)
|
||||
engine.record_cascade(symbols={"CROWN": [...]}, bombs=[Bomb("m1", 5)]) # 逐波消除
|
||||
result = engine.end_spin() # 末帧统一引爆 + 封顶 → SpinResult
|
||||
```
|
||||
|
||||
关键方法:`buy_free_spins_allowed()` / `ante_boost_active()` / `reset_session()`;非法操作抛 `RuleViolation`。
|
||||
|
||||
---
|
||||
|
||||
## 九、数据库表(前缀 `game_olympus_`,共 12 张)
|
||||
|
||||
| 表 | 用途 |
|
||||
|---|---|
|
||||
| `box` | 箱子配置(模式×档位,`box_type/bet/price`) |
|
||||
| `ornament` | 17 档饰品桶定义(种子随 schema 写入) |
|
||||
| `box_ornament` | 箱子-桶数量分布(`count/used`,后台调此控 RTP) |
|
||||
| `round` | 预生成回合(`ornament_id/actual_mul/free_spin_count/total_cascades`) |
|
||||
| `step` | 级联步骤明细(见下方字段) |
|
||||
| `record` | 玩家游戏记录(自带 `settled/结算/补发`字段) |
|
||||
| `user` / `win` | 用户统计 / 每日盈亏 |
|
||||
| `prize_ornament` / `prize_range` | 返奖饰品定义与区间 |
|
||||
| `box_template` | 箱子分布模板(抽干补桶依据) |
|
||||
| `reward_log` | 奖池水位调整日志(注水/抽水/重置审计,含操作后总池余额) |
|
||||
|
||||
**`step` 表核心字段**(前端播放 + 审计):
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `grid_state` | 本步 6×5 网格最终状态 |
|
||||
| `multiplier_detail` | 炸弹 `[{pos,val,quality}]` |
|
||||
| `symbol_links` | **本旋累积**连线 `[{symbol,count,loc,base_score,tumble}]`;`tumble` = 旋内第几波,**接口只下发这个字段**(本波新增 = `tumble` 等于本步 `cascade_seq` 的那些;获胜者面板按 symbol 汇总) |
|
||||
| `drop_data/fill_data` | 下落/填充动画数据 |
|
||||
| `scatter_trigger/retrigger_info` | 触发/重触发免费旋转信息(仅末帧) |
|
||||
| `accumulator_before/after` | 炸弹累计器前后值(跨旋传递);`after` **已下发**为接口字段 `accumulator`(未 ×bet,断线重连接回累计用),`before` 仍仅落库留档 |
|
||||
| `win_amount` | **本旋累计到本步的赢额**(该旋末帧即这一旋的总额;不是本步增量) |
|
||||
| `spin_win` | **本局累计到本步的赢额**(最后一步 = 整局实付 = 回合 `actual_mul`) |
|
||||
| `win_capped` | 本步是否被 1000x 截断 |
|
||||
| `bigwin_level` | 爆奖档位 `small/middle/big`(按单旋赢额,只在免费旋转里判档,盖在该旋末帧;基础旋恒为 null) |
|
||||
| `is_final` | 本旋末帧标记 |
|
||||
| `spin_winners` / `accumulated_symbols` / `removed_positions` / `base_score` | **仅落库留档,接口已不下发**(都能由 `symbol_links` 推出:面板=按 symbol 汇总、消除格位=上一步 `loc` 并集、基础分=`base_score` 之和) |
|
||||
|
||||
---
|
||||
|
||||
## 十、回合生成器(`olympus_simulator.py` + `generate_rounds.py`)
|
||||
|
||||
流程:`generate_round(box, bias)` → 生成 cascade 链 → 自然累计 → 落桶 → 发放截断。
|
||||
|
||||
- **职责边界**:生成器只负责"库存多样性"(每桶预生成足量各异回合),**不做 RTP 收敛**——RTP 由后台配 `box_ornament.count` 控制。
|
||||
- `fill_distribution(box, counts)`:两阶段填桶(自由生成 + 缺额加权 bias 补桶),保证每桶命中目标数量。
|
||||
- 关键实现:炸弹位置随重力下落同步重映射;免费旋转会话 15 起、≥3S 重触发 +5;`bias ≥ FS_ONLY_TARGET_MUL(80x)` 时改用"自然触发 FS"的回合来装该桶(见第三点五)。
|
||||
|
||||
---
|
||||
|
||||
## 十一、运行方式
|
||||
|
||||
> 环境:Python ≥ 3.9,依赖 `pymysql`(仅写库时需要)。DB 连接走环境变量 `OLY_DB_*`,默认对齐 `docker-compose.yml`(`127.0.0.1:23306 / changzhou`)。
|
||||
|
||||
```bash
|
||||
# 建表 + 写入饰品桶种子
|
||||
python olympus_db.py
|
||||
|
||||
# 干跑:三模式各桶抽样,验证多样性/封顶(不写库)
|
||||
python generate_rounds.py --per-bucket 20
|
||||
|
||||
# 全部 30 箱写库(推荐:池子按后台配比×1.5 分配,见下节"池子怎么分配")
|
||||
python generate_rounds.py --all --alloc config --write
|
||||
|
||||
# 备用:按每箱固定总回合跨桶均分(旧行为,正式发布可用 --per-box 2000)
|
||||
python generate_rounds.py --all --per-box 2000 --write
|
||||
|
||||
# 单箱写库
|
||||
python generate_rounds.py --box-type normal --bet 1 --alloc config --write
|
||||
|
||||
# 重跑前的安全流程(快照→清空→生成→校验;--alloc config 会自动保留配比,快照只作兜底)
|
||||
python _regen.py snapshot && python _regen.py clear
|
||||
python generate_rounds.py --all --alloc config --write # 保底消除改"随机间隔"后低倍桶命中率约减半,
|
||||
# fill_distribution 尝试上限已从 ×30 提到 ×60,
|
||||
# 全库重跑耗时约翻倍(30 箱 ≈45 分钟~1 小时)
|
||||
python _regen.py verify
|
||||
python _regen.py fsdry # 免费旋转连空体检(见四点五)
|
||||
python _recalc_boxes.py --mode normal && python _recalc_boxes.py --mode ante && python _recalc_boxes.py --mode buy_fs
|
||||
# 重算只动库里的 used,不清 Redis 账本 → 手动清 ruoyi:olympus:cycle:{boxId}:{audience},
|
||||
# 或后台对每个箱点一次「一键重算分布」;再跑 python _make_templates.py 刷新模板
|
||||
|
||||
# 重置(改 box_id 方案前用):清空 box/box_ornament/round/step/box_template
|
||||
python _reset_boxes.py
|
||||
|
||||
# 建奖池水位调整日志表(对齐 bullet 审计)
|
||||
python _create_reward_log.py
|
||||
|
||||
# 规则回归测试(25 项)
|
||||
python tests/test_engine.py
|
||||
```
|
||||
|
||||
**box_id 规则**:`--all` 按模式顺序×档位顺序**从 1 顺序编号**(normal 1~10、ante 11~20、buy_fs 21~30,共 30 箱)。
|
||||
|
||||
**池子怎么分配(重要)**:用 `--alloc config` —— **每桶池子 = 后台配比 × 1.5**(下限 20 条),而不是跨桶均分:
|
||||
|
||||
| 方式 | 效果 |
|
||||
|---|---|
|
||||
| `--per-box N`(旧,均分) | 每桶 N/桶数 条;normal 0x 桶只有 77 条,而配比一个 cycle 要发 548 次 → 同一条回合被反复发给玩家(**7.1×**) |
|
||||
| `--alloc config`(推荐) | 每桶 ≥ 配比;normal 0x 桶 837 条(配比 563)、buy_fs 低倍桶 447/280/174 条(配比 297/186/118)→ 每个回合一个 cycle 内最多发一次 |
|
||||
|
||||
`--alloc config` 还会把**现有配比原样写回**(不会把池子形状覆盖成 count),所以不需要再手工还原配比。
|
||||
池子形状与配比的对应关系写死在 `generate_rounds.py` 的 `ALLOC_K=1.5` / `ALLOC_FLOOR=60`,要调就改这两个常量。
|
||||
|
||||
> **为什么下限是 60(2026-09 调)**:池子 = `max(ALLOC_FLOOR, 配比×1.5)`,下限只作用在**配比 ≤13 的桶(6.5x 以上)**。
|
||||
> 这几个桶条数少、盘面又显眼(有赢奖/大奖动画),是玩家真正会"看着眼熟"的;0x~4x 虽然复现间隔最短
|
||||
> (约 4,500 抽一次),但空转/小赔的盘面没人记得住,所以不值得为它们整体加大池子(那就得动 `ALLOC_K`,
|
||||
> 池子 +64%、重跑时间 ×1.7)。下限 20→60 只让池子 +8%,把 6.5x 以上的复现间隔拉长 3 倍:
|
||||
> 10x 桶 5,455→16,364 抽、100x 桶 6 万→18 万抽(按全服每天 5,000 抽算,10x 从"几乎每天重"变成"三天重一次")。
|
||||
> 复现间隔 = `池子条数 ÷ 该桶配比 × 一轮抽数`,调这两个常量前建议照这个式子算一遍。
|
||||
|
||||
> 端侧配合:Java `OlympusRoundPicker` 已改为**同一玩家最近 30 条回合不再重发**(Redis List `ruoyi:olympus:seen:{userId}:{boxId}`,`LPUSH`+`LTRIM`),抽回合时从 40 条候选里过滤。
|
||||
> 两者叠加的效果:玩家在池子耗完前**不会看到重复盘面**;只靠加大池子做不到(要 ~1.4GB 数据且仍有 30% 重复)。
|
||||
|
||||
### 抽完补池(refill)与"实时 RTP 是否达标"仿真
|
||||
|
||||
一个 cycle 抽满后(`drawn ≥ N = fixedSum/(1−收敛比例)`),Java `OlympusGameService` 会 `resetCycle` + `refillFromConfig(boxId, audience)`:
|
||||
|
||||
| 情况 | 行为 |
|
||||
|---|---|
|
||||
| 该 (box, audience) 有**生效模板**(`is_active=1`,取 id 最大一条) | 按模板 `items_json` 重写 count(used 归 0),并按固定池反推回写 `total_ornaments` |
|
||||
| 没有模板 | 只把 `used` 归零(配比不变,等价于"填满"但形状不重置) |
|
||||
|
||||
建模板:`python _make_templates.py`(30 箱 × 4 身份 = 120 条生效模板,内容=当前配比;会先把旧的测试模板置为不生效)。
|
||||
|
||||
仿真(复刻 Java:固定阶段按 remaining 加权 + 收敛阶段 `desiredNextMul` + 水位 `payoutCap` + 抽完按模板重填):
|
||||
|
||||
```
|
||||
python simulate_cycle.py # 全部 30 箱 × common,3 个 cycle(水位跨 cycle 持久)
|
||||
python simulate_cycle.py --audience white # 白号(payoutCap=null,不进水位)
|
||||
python simulate_cycle.py --box 21 --cycles 5
|
||||
python simulate_cycle.py --cr 0.1 # 试不同收敛比例(只在仿真里覆盖,不写库)
|
||||
```
|
||||
|
||||
实测(rtp=80%、收敛比例=20%,30 箱 × 3 轮 × 1000 抽):
|
||||
|
||||
| 收敛比例 | 固定池末偏差 max/均值 | 整轮末偏差 max/均值 | 最差单轮 |
|
||||
|---|---|---|---|
|
||||
| 5% | 4.93% / 1.53% | 2.98% / 0.24% | 83.0% |
|
||||
| 10% | 6.55% / 1.65% | 1.78% / 0.08% | 81.8% |
|
||||
| 15% | 7.20% / 1.79% | 0.50% / 0.04% | 80.5% |
|
||||
| **20%(当前)** | 6.36% / 1.58% | **0.13% / 0.03%** | 79.9% |
|
||||
| 30% | 6.01% / 1.65% | 0.09% / 0.02% | 79.9% |
|
||||
|
||||
结论:固定阶段自身 ±6% 波动("配比即爆率"),全靠收敛阶段拉回目标;**收敛比例不要低于 15%**。
|
||||
水位只在冷启动时起作用(水位 0 → `payoutCap=(0+成本)×rtp`,首轮大奖被压住,导致第 1 轮固定池末偏低 77~78%,但整轮仍达标)。
|
||||
|
||||
---
|
||||
|
||||
## 十二、端到端数据流
|
||||
|
||||
```
|
||||
config.py(规则配置,唯一事实来源)
|
||||
│
|
||||
├─► engine.py 被 test_engine.py 回归验证(规则正确性)
|
||||
│
|
||||
└─► olympus_simulator.py 生成 round + step
|
||||
│
|
||||
▼
|
||||
generate_rounds.py 按桶批量生成
|
||||
│
|
||||
▼
|
||||
olympus_db.py → MySQL(game_olympus_round / _step / _box_ornament)
|
||||
│
|
||||
▼
|
||||
(后续)Java 服务端消费步骤数据 → dospin 逐步回放 + 结算发奖
|
||||
```
|
||||
|
||||
两层职责:**Python 预生成回合/步骤数据落库**,**Java 服务端实时回放与结算**(对齐 sweet 体系)。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
"""建奥林匹斯奖池调整日志表 game_olympus_reward_log(幂等,IF NOT EXISTS)。
|
||||
用法: python _create_reward_log.py
|
||||
"""
|
||||
import olympus_db as db
|
||||
|
||||
DDL = """
|
||||
CREATE TABLE IF NOT EXISTS game_olympus_reward_log (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
box_id BIGINT DEFAULT NULL COMMENT '箱子ID(批量/重置为汇总,可空)',
|
||||
box_name VARCHAR(100) DEFAULT NULL COMMENT '箱子名/汇总描述',
|
||||
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT '身份 common/anchor/robot/white',
|
||||
type TINYINT NOT NULL COMMENT '1注水 2抽水 3重置',
|
||||
amount DECIMAL(14,2) NOT NULL DEFAULT '0.00' COMMENT '调整金额(绝对值)',
|
||||
balance_before DECIMAL(14,2) DEFAULT NULL COMMENT '调整前水位',
|
||||
balance_after DECIMAL(14,2) DEFAULT NULL COMMENT '调整后水位',
|
||||
pool_balance DECIMAL(14,2) DEFAULT NULL COMMENT '操作后总池余额',
|
||||
operator VARCHAR(64) DEFAULT NULL COMMENT '操作人',
|
||||
remark VARCHAR(200) DEFAULT NULL COMMENT '备注',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_create (create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus奖池水位调整日志';
|
||||
"""
|
||||
|
||||
with db.get_conn() as conn:
|
||||
conn.cursor().execute(DDL)
|
||||
print('game_olympus_reward_log ready.')
|
||||
@@ -0,0 +1,71 @@
|
||||
"""给每箱 × 每身份建"默认配比"模板并置为生效。
|
||||
|
||||
为什么需要:箱子的一个 cycle 抽满后(drawn ≥ N),Java 会
|
||||
cycleRepository.resetCycle() + distributionService.refillFromConfig(boxId, audience)
|
||||
而 refillFromConfig 是**按 (boxId, audience) 查生效模板**(is_active=1,取 id 最大的一条):
|
||||
- 有生效模板 → 按模板 items_json 重写 count(writeDistribution),并按固定池反推回写 total_ornaments
|
||||
- 没有模板 → 只把 used 归零(配比不变)
|
||||
所以要让"抽完按模板重填",必须给每个 (box, audience) 都有一条生效模板。
|
||||
|
||||
用法:
|
||||
python _make_templates.py --dry # 只看会写什么
|
||||
python _make_templates.py # 写入
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
import olympus_db as db
|
||||
from olympus.config import AUDIENCES
|
||||
|
||||
TEMPLATE_NAME = '默认配比'
|
||||
|
||||
|
||||
def build_rows():
|
||||
rows = []
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT box_id, box_type, bet, rtp FROM game_olympus_box ORDER BY box_id')
|
||||
boxes = cur.fetchall()
|
||||
cur.execute('''SELECT box_id, ornament_id, audience, count
|
||||
FROM game_olympus_box_ornament ORDER BY box_id, audience, ornament_id''')
|
||||
cfg = defaultdict(dict)
|
||||
for box_id, orn, aud, cnt in cur.fetchall():
|
||||
cfg[(int(box_id), aud)][int(orn)] = int(cnt)
|
||||
for box_id, box_type, bet, rtp in boxes:
|
||||
for aud in AUDIENCES:
|
||||
items = cfg.get((int(box_id), aud), {})
|
||||
if not items:
|
||||
print('!! box%s %s 无配比,跳过' % (box_id, aud))
|
||||
continue
|
||||
items_json = json.dumps(
|
||||
[{'ornamentId': k, 'count': v} for k, v in sorted(items.items())],
|
||||
ensure_ascii=False, separators=(',', ':'))
|
||||
rows.append((int(box_id), aud, TEMPLATE_NAME, sum(items.values()), items_json))
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--dry', action='store_true')
|
||||
args = ap.parse_args()
|
||||
rows = build_rows()
|
||||
print('待写模板 %d 条(%d 箱 × %d 身份)' % (len(rows), len(rows) // len(AUDIENCES), len(AUDIENCES)))
|
||||
if args.dry:
|
||||
print('样例:', rows[0][:4], rows[0][4][:80], '...')
|
||||
return
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
# 先取消原有生效模板,再 upsert(表上有 uk_box_aud_name 唯一键)
|
||||
cur.execute('''UPDATE game_olympus_box_template SET is_active=0''')
|
||||
cur.executemany('''INSERT INTO game_olympus_box_template
|
||||
(box_id, audience, name, total_rounds, items_json, is_active, create_time)
|
||||
VALUES (%s,%s,%s,%s,%s,1,NOW())
|
||||
ON DUPLICATE KEY UPDATE total_rounds=VALUES(total_rounds),
|
||||
items_json=VALUES(items_json), is_active=1, create_time=NOW()''', rows)
|
||||
cur.execute('''SELECT COUNT(*), SUM(is_active) FROM game_olympus_box_template''')
|
||||
print('模板表: 总行数/生效行数 =', cur.fetchone())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,183 @@
|
||||
"""复刻后端 OlympusDistributionService.calcRounds + reset(),重算某模式各箱的桶配比。
|
||||
|
||||
为什么需要:`OlympusModeType.getOrnamentRange()` 改了(buy_fs 从 5~17 改成 7~17)之后,
|
||||
后台要「一键重算」才会按新范围重配 count。这个脚本把同一套算法跑在 py 侧,
|
||||
用来在没有后台操作的场合先把配比算出来(结果与后端一键重算一致)。
|
||||
|
||||
算法移植自 Java(calcRounds):基础权重 30×0.6^k → 二分 power 命中目标均值 →
|
||||
按权重分配固定池 → 单调约束 count[i] ≥ count[i+1] → 只在最大的 3 个桶间微调逼近目标。
|
||||
|
||||
用法:
|
||||
python _recalc_boxes.py --mode buy_fs --dry # 只看结果
|
||||
python _recalc_boxes.py --mode buy_fs # 写库(count 覆盖、used 归零)
|
||||
"""
|
||||
import argparse
|
||||
|
||||
import olympus_db as db
|
||||
from olympus.config import MODES, MODE_ORNAMENT_IDS, ORNAMENTS, payout_expect
|
||||
|
||||
# 回退用的"桶期望":桶 17 展示 1000x 但封顶发放,按实测值算(见 config.PAYOUT_EXPECT_OVERRIDE)
|
||||
_ORN_MUL = {o['id']: payout_expect(o['id']) for o in ORNAMENTS}
|
||||
|
||||
# 该桶池子里一条回合都没有时才回退到 _ORN_MUL(名义/封顶修正值)。
|
||||
# 只要池子里有回合,就用它的实际均值:箱子发的是**自己这批固定回合**,它的均值就是这个箱子的真实期望。
|
||||
MIN_POOL_SAMPLE = 1
|
||||
|
||||
|
||||
def load_pool_expect(box_id, mode_code):
|
||||
"""该箱池子里每个桶的**实际**均值(AVG(actual_mul))——配比按"这个箱子真正会赔出去多少"算,
|
||||
而不是按桶区间中点:桶内均值偏差(实测 -18%~+11%)和每箱池子的抽样差异会直接变成 RTP 偏差。"""
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('''SELECT ornament_id, COUNT(*), AVG(actual_mul) FROM game_olympus_round
|
||||
WHERE box_id=%s GROUP BY ornament_id''', (box_id,))
|
||||
rows = cur.fetchall()
|
||||
exp = {i: _ORN_MUL[i] for i in MODE_ORNAMENT_IDS[mode_code]}
|
||||
for oid, n, avg in rows:
|
||||
if int(oid) in exp and int(n) >= MIN_POOL_SAMPLE and avg and float(avg) > 0:
|
||||
exp[int(oid)] = float(avg)
|
||||
return exp
|
||||
|
||||
|
||||
def derive_total_rounds(fixed_rounds, cr):
|
||||
cr = min(max(cr, 0.0), 0.9)
|
||||
return int(fixed_rounds / (1.0 - cr) + 0.5)
|
||||
|
||||
|
||||
def derive_fixed_rounds(total_rounds, cr):
|
||||
cr = min(max(cr, 0.0), 0.9)
|
||||
return int(total_rounds * (1.0 - cr) + 0.5)
|
||||
|
||||
|
||||
def calc_rounds(mode_code, total_rounds, target_rtp, expect=None):
|
||||
"""返回 {ornament_id: count},合计 = total_rounds(对齐 Java calcRounds)
|
||||
|
||||
expect:桶 → 该桶"实际赔出去多少"的期望;不传则用名义值(含桶 17 封顶修正)。"""
|
||||
mul = expect or _ORN_MUL
|
||||
cm = float(MODES[mode_code].cost_multiplier)
|
||||
target_avg = target_rtp * cm
|
||||
ids = sorted(MODE_ORNAMENT_IDS[mode_code])
|
||||
n = len(ids)
|
||||
if total_rounds <= n:
|
||||
return {i: 1 for i in ids}
|
||||
base_w = [30.0 * (0.6 ** i) for i in range(n)]
|
||||
remaining = total_rounds - n
|
||||
guaranteed = sum(mul[i] for i in ids)
|
||||
rem_target_avg = (target_avg * total_rounds - guaranteed) / remaining
|
||||
power, plo, phi = 1.0, 0.2, 10.0
|
||||
for _ in range(30):
|
||||
ws = [w ** power for w in base_w]
|
||||
sw = sum(ws)
|
||||
avg = sum(w * mul[i] for w, i in zip(ws, ids)) / sw if sw > 0 else 0.0
|
||||
if abs(avg - rem_target_avg) < max(rem_target_avg * 0.01, 0.001):
|
||||
break
|
||||
if avg < rem_target_avg:
|
||||
phi = power
|
||||
else:
|
||||
plo = power
|
||||
power = (plo + phi) / 2
|
||||
ws = [w ** power for w in base_w]
|
||||
tw = sum(ws)
|
||||
result = {i: 1 for i in ids}
|
||||
rem = remaining
|
||||
for i, w in zip(ids, ws):
|
||||
extra = int(w / tw * remaining + 0.5) if tw > 0 else 0
|
||||
put = min(extra, rem)
|
||||
if put > 0:
|
||||
result[i] += put
|
||||
rem -= put
|
||||
if rem > 0:
|
||||
result[ids[0]] += rem
|
||||
for k in range(n - 2, -1, -1): # 单调 count[i] >= count[i+1]
|
||||
if result[ids[k]] < result[ids[k + 1]]:
|
||||
result[ids[k]] = result[ids[k + 1]]
|
||||
diff = total_rounds - sum(result.values())
|
||||
if diff:
|
||||
result[ids[0]] += diff
|
||||
for _ in range(400): # 微调:任取两个桶搬 1 个(保持单调),只要更接近目标
|
||||
cur_mul = sum(result[i] * mul[i] for i in ids)
|
||||
delta = target_avg * total_rounds - cur_mul
|
||||
if abs(delta) < 0.01:
|
||||
break
|
||||
best, best_diff = None, abs(delta)
|
||||
for lo in ids:
|
||||
if result[lo] <= 1:
|
||||
continue
|
||||
for hi in ids:
|
||||
if lo == hi:
|
||||
continue
|
||||
change = mul[hi] - mul[lo]
|
||||
if abs(change) < 0.001:
|
||||
continue
|
||||
nd = abs(delta - change)
|
||||
if nd < best_diff:
|
||||
ok = True
|
||||
ca, idx_lo = result[lo] - 1, ids.index(lo)
|
||||
if idx_lo < n - 1 and ca < result[ids[idx_lo + 1]]:
|
||||
ok = False
|
||||
ca2, idx_hi = result[hi] + 1, ids.index(hi)
|
||||
if idx_hi > 0 and ca2 > result[ids[idx_hi - 1]]:
|
||||
ok = False
|
||||
if ids.index(lo) < ids.index(hi) and ca < ca2:
|
||||
ok = False
|
||||
if ok:
|
||||
best, best_diff = (lo, hi), nd
|
||||
if best is None:
|
||||
break
|
||||
result[best[0]] -= 1
|
||||
result[best[1]] += 1
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--mode', default='buy_fs')
|
||||
ap.add_argument('--dry', action='store_true')
|
||||
args = ap.parse_args()
|
||||
from olympus.config import AUDIENCES
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('''SELECT box_id, rtp, rtp_converge_ratio, total_ornaments
|
||||
FROM game_olympus_box WHERE box_type=%s ORDER BY box_id''', (args.mode,))
|
||||
boxes = cur.fetchall()
|
||||
print('%s 共 %d 箱,桶范围 %s;池内实际均值按**每个箱子自己的池子**算(样本<%d的桶回退名义值)'
|
||||
% (args.mode, len(boxes), sorted(MODE_ORNAMENT_IDS[args.mode]), MIN_POOL_SAMPLE))
|
||||
for box_id, rtp, cr, total in boxes:
|
||||
rtp = float(rtp or 0.8)
|
||||
cr = float(cr if cr is not None else 0.15)
|
||||
fixed = derive_fixed_rounds(int(total or 1000), cr)
|
||||
pool_expect = load_pool_expect(box_id, args.mode)
|
||||
counts = calc_rounds(args.mode, fixed, rtp, pool_expect)
|
||||
s = sum(counts.values())
|
||||
cm = float(MODES[args.mode].cost_multiplier)
|
||||
pay_pool = sum(counts[i] * pool_expect[i] for i in counts)
|
||||
pay_nominal = sum(counts[i] * _ORN_MUL[i] for i in counts)
|
||||
print('box%-3d 固定池%d 配比 %s' % (
|
||||
box_id, s, {k: v for k, v in sorted(counts.items()) if v > 0}))
|
||||
print(' 目标RTP(本箱池内实际口径) %.2f%% 名义口径 %.2f%%'
|
||||
% (100.0 * pay_pool / s / cm, 100.0 * pay_nominal / s / cm))
|
||||
if args.dry:
|
||||
continue
|
||||
n_cycle = derive_total_rounds(s, cr)
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
for aud in AUDIENCES:
|
||||
cur.execute('DELETE FROM game_olympus_box_ornament WHERE box_id=%s AND audience=%s',
|
||||
(box_id, aud))
|
||||
cur.executemany('''INSERT INTO game_olympus_box_ornament
|
||||
(box_id, ornament_id, audience, count, used) VALUES (%s,%s,%s,%s,0)''',
|
||||
[(box_id, i, aud, c) for i, c in sorted(counts.items()) if c > 0])
|
||||
cur.execute('UPDATE game_olympus_box SET total_ornaments=%s WHERE box_id=%s',
|
||||
(n_cycle, box_id))
|
||||
if not args.dry:
|
||||
print('已写库(count 覆盖、used 归零、total_ornaments=一轮抽数)。')
|
||||
print('提醒:本脚本只动库里的 used,**不会清 Redis 的本轮账本**'
|
||||
'(ruoyi:olympus:cycle:{boxId}:{audience});不清的话后台「实时RTP」会把'
|
||||
'重置前的旧抽数和新抽数混在一起显示。'
|
||||
'清账本:docker exec redis_cache redis-cli -a <pwd> DEL ruoyi:olympus:cycle:<boxId>:<audience>,'
|
||||
'或在后台对这几个箱点一次「一键重算分布」(它会同时清账本)。')
|
||||
print('记得再跑 python _make_templates.py 刷新模板。')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,329 @@
|
||||
"""重新生成回合数据前的检查 / 快照 / 清空 / 还原(一次性脚本,用完可删)。
|
||||
|
||||
python _regen.py check # 查看箱配置、桶配比、回合池构成
|
||||
python _regen.py snapshot # 备份 box / box_ornament / box_template 到 JSON
|
||||
python _regen.py clear # 清空 game_olympus_step + game_olympus_round
|
||||
python _regen.py restore # 从备份还原 box_ornament 的 count(used 归 0)
|
||||
python _regen.py verify # 校验:buy_fs 触发盘 4S0M、FS 内仍有炸弹、池子每桶 77
|
||||
python _regen.py fsstats [box_id] # 免费旋转观感统计:每局几次消除、几次炸弹并入、累计倍数
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import olympus_db as db
|
||||
|
||||
SNAP = Path(__file__).resolve().parent / '_backup_box_config.json'
|
||||
|
||||
|
||||
def check():
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('''SELECT box_id, box_type, bet, total_ornaments, rtp
|
||||
FROM game_olympus_box ORDER BY box_id''')
|
||||
boxes = cur.fetchall()
|
||||
print('boxes=%d' % len(boxes))
|
||||
for b in boxes[:3] + boxes[-2:]:
|
||||
print(' ', b)
|
||||
cur.execute('''SELECT audience, COUNT(DISTINCT box_id), SUM(count), SUM(used)
|
||||
FROM game_olympus_box_ornament GROUP BY audience''')
|
||||
print('box_ornament:', cur.fetchall())
|
||||
cur.execute('''SELECT box_id, audience, COUNT(*) n, SUM(count) total, SUM(used) used
|
||||
FROM game_olympus_box_ornament WHERE box_id IN (1,21,30)
|
||||
GROUP BY box_id, audience ORDER BY box_id''')
|
||||
for r in cur.fetchall():
|
||||
print(' cfg', r)
|
||||
for box_id in (1, 21, 30):
|
||||
cur.execute('''SELECT ornament_id, COUNT(*) FROM game_olympus_round
|
||||
WHERE box_id=%s GROUP BY ornament_id ORDER BY ornament_id''',
|
||||
(box_id,))
|
||||
print(' pool box%d:' % box_id, cur.fetchall())
|
||||
cur.execute('''SELECT ornament_id, count FROM game_olympus_box_ornament
|
||||
WHERE box_id=21 AND audience='common' ORDER BY ornament_id''')
|
||||
print(' cfg box21 common:', cur.fetchall())
|
||||
cur.execute('''SELECT ornament_id, count FROM game_olympus_box_ornament
|
||||
WHERE box_id=30 AND audience='common' ORDER BY ornament_id''')
|
||||
print(' cfg box30 common:', cur.fetchall())
|
||||
|
||||
|
||||
def snapshot():
|
||||
data = {'ts': datetime.now().isoformat(timespec='seconds')}
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor(db.pymysql.cursors.DictCursor)
|
||||
for t in ('game_olympus_box', 'game_olympus_box_ornament', 'game_olympus_box_template'):
|
||||
cur.execute('SELECT * FROM ' + t)
|
||||
data[t] = [{k: (str(v) if v is not None else None) for k, v in row.items()}
|
||||
for row in cur.fetchall()]
|
||||
SNAP.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding='utf-8')
|
||||
print('snapshot ->', SNAP,
|
||||
{k: len(v) for k, v in data.items() if k != 'ts'})
|
||||
|
||||
|
||||
def clear():
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
for t in ('game_olympus_step', 'game_olympus_round'):
|
||||
cur.execute('TRUNCATE TABLE ' + t)
|
||||
print('truncated', t)
|
||||
print('max_round_id =', db.get_max_round_id(), 'max_step_id =', db.get_max_step_id())
|
||||
|
||||
|
||||
def restore():
|
||||
data = json.loads(SNAP.read_text(encoding='utf-8'))
|
||||
rows = data['game_olympus_box_ornament']
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT COUNT(*) FROM game_olympus_box_ornament')
|
||||
before = cur.fetchone()[0]
|
||||
cur.execute('DELETE FROM game_olympus_box_ornament')
|
||||
cur.executemany('''INSERT INTO game_olympus_box_ornament
|
||||
(box_id, ornament_id, audience, count, used) VALUES (%s,%s,%s,%s,0)''',
|
||||
[(int(r['box_id']), int(r['ornament_id']), r['audience'],
|
||||
int(r['count'])) for r in rows])
|
||||
print('box_ornament: %d -> %d rows (used=0)' % (before, len(rows)))
|
||||
# box 行对比:库里现值 vs 备份值
|
||||
dc = conn.cursor(db.pymysql.cursors.DictCursor)
|
||||
dc.execute('''SELECT box_id, box_type, bet, total_ornaments, rtp, price, status, sort
|
||||
FROM game_olympus_box ORDER BY box_id''')
|
||||
old = {int(r['box_id']): r for r in data['game_olympus_box']}
|
||||
for r in dc.fetchall():
|
||||
o = old.get(int(r['box_id']))
|
||||
if o is None:
|
||||
print(' box %s: 新增' % r['box_id'])
|
||||
continue
|
||||
diff = {k: (o[k], str(r[k])) for k in r
|
||||
if str(o[k]) != str(r[k])}
|
||||
if diff:
|
||||
print(' box %s 变化:' % r['box_id'], diff)
|
||||
|
||||
|
||||
def verify():
|
||||
"""grid_state 里数 '"S"'/'"M"' 出现次数(短码是独立 JSON 字符串,不会误匹配符号名)。"""
|
||||
cnt = ("(CHAR_LENGTH(s.grid_state)-CHAR_LENGTH(REPLACE(s.grid_state,'\"%s\"','')))/3")
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT COUNT(*) FROM game_olympus_round')
|
||||
rounds = cur.fetchone()[0]
|
||||
cur.execute('SELECT COUNT(*) FROM game_olympus_step')
|
||||
steps = cur.fetchone()[0]
|
||||
cur.execute('SELECT box_id, COUNT(*) FROM game_olympus_round GROUP BY box_id ORDER BY box_id')
|
||||
per_box = {r[0]: r[1] for r in cur.fetchall()}
|
||||
print('rounds=%d steps=%d 每箱回合=%s' % (rounds, steps, sorted(set(per_box.values()))))
|
||||
cur.execute('''SELECT COUNT(*) FROM game_olympus_round WHERE box_id NOT BETWEEN 1 AND 30''')
|
||||
print('越界 box_id 回合:', cur.fetchone()[0])
|
||||
|
||||
# buy_fs 触发盘:每步 4 个 S、0 个 M
|
||||
cur.execute(f'''SELECT COUNT(*), SUM({cnt % 'S'}), SUM({cnt % 'M'}), MAX(s.cascade_seq)
|
||||
FROM game_olympus_step s JOIN game_olympus_round r USING(round_id)
|
||||
WHERE r.box_id BETWEEN 21 AND 30 AND s.free_spin_id=0''')
|
||||
n, sc, bm, maxseq = cur.fetchone()
|
||||
print('buy_fs 触发盘: 步数=%s S总数=%s(应=%s) M总数=%s(应=0) 最大级联页=%s'
|
||||
% (n, sc, 4 * (n or 0), bm, maxseq))
|
||||
|
||||
# 免费旋转内仍然有炸弹(累计器机制没被误伤)
|
||||
cur.execute(f'''SELECT COUNT(*), SUM({cnt % 'M'}) FROM game_olympus_step s
|
||||
JOIN game_olympus_round r USING(round_id)
|
||||
WHERE s.free_spin_id > 0''')
|
||||
fn, fm = map(float, cur.fetchone())
|
||||
fm = fm or 0.0
|
||||
cur.execute(f'''SELECT COUNT(*), SUM({cnt % 'M'}) FROM game_olympus_step s
|
||||
JOIN game_olympus_round r USING(round_id)
|
||||
WHERE r.box_id <= 10 AND s.free_spin_id = 0''')
|
||||
nn, nm = map(float, cur.fetchone())
|
||||
nm = nm or 0.0
|
||||
print('FS 步数=%s 带炸弹格=%s (%.1f%%)' % (fn, fm, 100.0 * (fm or 0) / (30 * fn)))
|
||||
print('normal 基础盘步数=%s 带炸弹格=%s (%.1f%%)' % (nn, nm, 100.0 * (nm or 0) / (30 * nn)))
|
||||
|
||||
# ante 基础旋同样不许有炸弹;normal/ante 的 100x+ 桶(基础旋无炸弹赔不出来)应全是 FS 回合
|
||||
cur.execute(f'''SELECT COUNT(*), SUM({cnt % 'M'}) FROM game_olympus_step s
|
||||
JOIN game_olympus_round r USING(round_id)
|
||||
WHERE r.box_id BETWEEN 11 AND 20 AND s.free_spin_id = 0''')
|
||||
an, am = map(float, cur.fetchone())
|
||||
am = am or 0.0
|
||||
print('ante 基础盘步数=%s 带炸弹格=%s (%.1f%%)' % (an, am, 100.0 * am / (30 * an)))
|
||||
cur.execute('''SELECT r.ornament_id, COUNT(*) 回合, SUM(r.free_spin_count>0) FS回合
|
||||
FROM game_olympus_round r WHERE r.box_id <= 20 AND r.ornament_id >= 12
|
||||
GROUP BY r.ornament_id ORDER BY r.ornament_id''')
|
||||
print('normal/ante 100x+ 桶(应 回合=FS回合):', cur.fetchall())
|
||||
cur.execute('''SELECT r.box_id, COUNT(*) 回合, SUM(r.free_spin_count>0) FS回合
|
||||
FROM game_olympus_round r WHERE r.box_id <= 20
|
||||
GROUP BY r.box_id ORDER BY r.box_id''')
|
||||
print('normal/ante 每箱 FS 回合数:', cur.fetchall())
|
||||
|
||||
# 爆奖档位:只有小/中/大,且只落在免费旋转的旋末帧
|
||||
cur.execute('''SELECT bigwin_level, COUNT(*) FROM game_olympus_step
|
||||
GROUP BY bigwin_level ORDER BY bigwin_level''')
|
||||
print('爆奖档位分布:', cur.fetchall())
|
||||
cur.execute('''SELECT COUNT(*) FROM game_olympus_step
|
||||
WHERE bigwin_level IS NOT NULL AND (is_final <> 1 OR free_spin_id = 0)''')
|
||||
print('爆奖档位落在基础旋/非末帧的行数(应=0):', cur.fetchone()[0])
|
||||
|
||||
# 盘面绝不能有空格(前端会显示成空白格子)
|
||||
cur.execute('''SELECT COUNT(*) FROM game_olympus_step WHERE grid_state LIKE '%""%' ''')
|
||||
print('盘面空白格行数(应=0):', cur.fetchone()[0])
|
||||
|
||||
# 池子构成(每桶应≈77)
|
||||
cur.execute('''SELECT box_id, MIN(c), MAX(c) FROM
|
||||
(SELECT box_id, ornament_id, COUNT(*) c FROM game_olympus_round
|
||||
GROUP BY box_id, ornament_id) t GROUP BY box_id ORDER BY box_id''')
|
||||
print('每箱桶内回合数 min/max:', sorted({(r[1], r[2]) for r in cur.fetchall()}))
|
||||
|
||||
# 配比是否为后台调好的曲线(非均分)
|
||||
cur.execute('''SELECT box_id, SUM(count) FROM game_olympus_box_ornament
|
||||
GROUP BY box_id, audience ORDER BY box_id LIMIT 3''')
|
||||
print('box_ornament 前3箱×(身份) sum(count):', cur.fetchall())
|
||||
|
||||
|
||||
def fsstats():
|
||||
"""免费旋转观感统计(按桶):每局"有消除的旋数"、"炸弹并入累计器的次数"、局末累计倍数中位数。
|
||||
玩家真正感受到的是"按后台配比加权"后的那一行 —— 低倍桶占比高时观感就被低倍桶决定。"""
|
||||
import statistics
|
||||
from collections import defaultdict, Counter
|
||||
from olympus.config import ORNAMENTS
|
||||
_orn = {o['id']: o['name'] for o in ORNAMENTS}
|
||||
box_id = int(sys.argv[2]) if len(sys.argv) > 2 else 21
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('''SELECT r.round_id, r.ornament_id, s.free_spin_id, s.base_score,
|
||||
s.accumulator_before, s.accumulator_after
|
||||
FROM game_olympus_step s JOIN game_olympus_round r USING(round_id)
|
||||
WHERE r.box_id=%s AND s.free_spin_id>0 ORDER BY s.round_id, s.id''', (box_id,))
|
||||
rows = cur.fetchall()
|
||||
cur.execute('''SELECT ornament_id, count FROM game_olympus_box_ornament
|
||||
WHERE box_id=%s AND audience=%s''', (box_id, 'common'))
|
||||
cfg = {int(a): int(b) for a, b in cur.fetchall()}
|
||||
cur.execute('SELECT ornament_id, AVG(actual_mul) FROM game_olympus_round WHERE box_id=%s GROUP BY ornament_id', (box_id,))
|
||||
avg = {int(a): float(b) for a, b in cur.fetchall()}
|
||||
spins = {}
|
||||
for rid, orn, fs, base, ab, aa in rows:
|
||||
d = spins.setdefault((rid, orn, fs), {'elim': False, 'ab': 0.0, 'aa': 0.0})
|
||||
if float(base or 0) > 0:
|
||||
d['elim'] = True
|
||||
d['ab'] = float(ab or 0)
|
||||
d['aa'] = float(aa or 0)
|
||||
per = defaultdict(Counter)
|
||||
rounds_of = defaultdict(set)
|
||||
acc_of = {}
|
||||
for (rid, orn, fs), d in spins.items():
|
||||
c = per[orn]
|
||||
c['spins'] += 1
|
||||
rounds_of[orn].add(rid)
|
||||
acc_of[rid] = max(acc_of.get(rid, 0.0), d['aa'])
|
||||
if d['elim']:
|
||||
c['elim'] += 1
|
||||
if d['aa'] > d['ab']:
|
||||
c['merges'] += 1
|
||||
total = sum(cfg.values()) or 1
|
||||
print('box%d 免费旋转观感(桶 抽中率 | 每局消除旋数 | 每局炸弹并入 | 局末累计中位 | 该桶实际均值)' % box_id)
|
||||
w_elim = w_merges = 0.0
|
||||
for orn in sorted(per):
|
||||
c = per[orn]
|
||||
n = len(rounds_of[orn]) or 1
|
||||
elim_per, merge_per = c['elim'] / n, c['merges'] / n
|
||||
accs = [acc_of[r] for r in rounds_of[orn]]
|
||||
w = cfg.get(orn, 0) / total
|
||||
w_elim += w * elim_per
|
||||
w_merges += w * merge_per
|
||||
print(' %2d %6sx %5.1f%% | %6.1f 旋 | %6.2f 次 | %8.0f | %8.2f' % (
|
||||
orn, _orn.get(orn, '?'), 100 * w, elim_per, merge_per,
|
||||
statistics.median(accs) if accs else 0, avg.get(orn, 0)))
|
||||
print(' ── 玩家加权(按配比):每局 %.1f 旋有消除、%.2f 次炸弹并入' % (w_elim, w_merges))
|
||||
|
||||
|
||||
def fsdry():
|
||||
"""免费旋转"连续空转"统计:一局里最长连续几旋一个符号都没消(玩家观感=一直白转)。
|
||||
|
||||
dry = 该旋所有步的 base_score 都是 0(scatter 赔付不算消除:S 不参与消除)。
|
||||
只统计有 FS 的回合;1000x 封顶截断的回合里被丢掉的旋按缺失处理,不参与连击计数。
|
||||
"""
|
||||
from collections import Counter, defaultdict
|
||||
from olympus.config import ORNAMENTS
|
||||
_orn = {o['id']: o['name'] for o in ORNAMENTS}
|
||||
box_id = int(sys.argv[2]) if len(sys.argv) > 2 else None
|
||||
sql = '''SELECT r.round_id, r.box_id, r.ornament_id, s.free_spin_id,
|
||||
SUM(s.base_score > 0) elim
|
||||
FROM game_olympus_step s JOIN game_olympus_round r USING(round_id)
|
||||
WHERE s.free_spin_id > 0'''
|
||||
args = ()
|
||||
if box_id:
|
||||
sql += ' AND r.box_id=%s'
|
||||
args = (box_id,)
|
||||
sql += ''' GROUP BY r.round_id, r.box_id, r.ornament_id, s.free_spin_id
|
||||
ORDER BY r.round_id, s.free_spin_id'''
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql, args)
|
||||
rows = cur.fetchall()
|
||||
cur.execute('SELECT box_id, box_type FROM game_olympus_box')
|
||||
btype = {int(a): str(b) for a, b in cur.fetchall()}
|
||||
cur.execute("""SELECT box_id, ornament_id, count FROM game_olympus_box_ornament
|
||||
WHERE audience='common'""")
|
||||
cfg = defaultdict(dict)
|
||||
for b, o, c in cur.fetchall():
|
||||
cfg[int(b)][int(o)] = int(c)
|
||||
|
||||
per_round, meta = defaultdict(list), {}
|
||||
for rid, b, o, fs, elim in rows:
|
||||
per_round[int(rid)].append((int(fs), float(elim or 0) > 0))
|
||||
meta[int(rid)] = (int(b), int(o))
|
||||
|
||||
# rid -> (box, orn, 旋数, 有消除旋数, 最长连空)
|
||||
stat = {}
|
||||
for rid, seq in per_round.items():
|
||||
run = mx = 0
|
||||
for _, e in sorted(seq):
|
||||
run = 0 if e else run + 1
|
||||
mx = max(mx, run)
|
||||
stat[rid] = (meta[rid][0], meta[rid][1], len(seq), sum(e for _, e in seq), mx)
|
||||
|
||||
def show(tag, rids, weight_by_cfg=False):
|
||||
"""weight_by_cfg:≥4 连空的概率按后台配比 count 加权(= 玩家实际遇到的概率)"""
|
||||
if not rids:
|
||||
return []
|
||||
dist, wsum, p4 = Counter(), 0.0, 0.0
|
||||
spins = elim = 0
|
||||
worst = []
|
||||
for rid in rids:
|
||||
b, o, n, e, mx = stat[rid]
|
||||
w = (cfg.get(b, {}).get(o, 0) or 1) if weight_by_cfg else 1
|
||||
wsum += w
|
||||
spins += n
|
||||
elim += e
|
||||
dist[min(mx, 6)] += 1 # 6 及以上并作一格,否则 >6 的局不进任何格、占比会莫名少一截
|
||||
if mx >= 4:
|
||||
p4 += w
|
||||
worst.append((mx, rid))
|
||||
n = len(rids)
|
||||
worst.sort(reverse=True)
|
||||
print('%-12s 局=%5d 旋/局=%5.1f 有消除=%4.1f/局(%4.1f%%) | 最长连空 0~6+ : %s | ≥4连空 %5.1f%%'
|
||||
% (tag, n, spins / n, elim / n, 100.0 * elim / max(1, spins),
|
||||
' '.join('%d%s:%.0f%%' % (k, '+' if k == 6 else '', 100.0 * dist[k] / n)
|
||||
for k in range(7)),
|
||||
100.0 * p4 / wsum))
|
||||
return worst
|
||||
|
||||
by_mode = defaultdict(list)
|
||||
for rid, (b, o) in meta.items():
|
||||
by_mode[btype.get(b, '?')].append(rid)
|
||||
print('=== 免费旋转"连续空转"(dry = 该旋 base_score 全 0;≥4连空 一列买 FS 按配比加权)===')
|
||||
all_worst = []
|
||||
for mode in ('normal', 'ante', 'buy_fs'):
|
||||
rids = by_mode.get(mode) or []
|
||||
if not rids:
|
||||
continue
|
||||
all_worst += [(mx, rid, mode, meta[rid]) for mx, rid in show(mode, rids)[:5]]
|
||||
by_orn = defaultdict(list) # 同一模式的 10 个箱合并看桶
|
||||
for rid in rids:
|
||||
by_orn[meta[rid][1]].append(rid)
|
||||
for o in sorted(by_orn):
|
||||
show(' %s %s' % (mode, _orn.get(o, o)), by_orn[o], weight_by_cfg=True)
|
||||
all_worst.sort(reverse=True)
|
||||
print('--- 最差局(最长连空 / round_id / 模式 / 桶)---')
|
||||
for mx, rid, mode, (b, o) in all_worst[:12]:
|
||||
print(' 连空%d旋 round=%d %s box%d 桶%s' % (mx, rid, mode, b, _orn.get(o, o)))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else 'check'
|
||||
globals()[cmd]()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""一次性重置:清空箱子相关数据(box_id 改从 1 起、总回合调整时用)。
|
||||
保留 game_olympus_ornament(桶定义) 与 game_olympus_prize_*(返奖饰品)。
|
||||
用法: python _reset_boxes.py
|
||||
"""
|
||||
import olympus_db as db
|
||||
|
||||
# 子表在前(无外键,仅为清晰)
|
||||
TABLES = [
|
||||
'game_olympus_step',
|
||||
'game_olympus_round',
|
||||
'game_olympus_box_ornament',
|
||||
'game_olympus_box_template',
|
||||
'game_olympus_box',
|
||||
]
|
||||
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
for t in TABLES:
|
||||
cur.execute(f'TRUNCATE TABLE {t}')
|
||||
print('truncated', t)
|
||||
print('reset done. max_round_id =', db.get_max_round_id())
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
Olympus 回合生成运行入口 — 只负责回合库存的多样性
|
||||
|
||||
职责说明:
|
||||
- Python 端: 为模式允许的每个饰品桶预生成足够数量的回合(库存),保证多样性
|
||||
- 后台端: 通过配置各桶的数量分布(game_olympus_box_ornament.count)控制箱子
|
||||
抽完的实时 RTP 是否达到目标,本脚本不做 RTP 收敛
|
||||
|
||||
用法:
|
||||
python generate_rounds.py # 干跑: 每模式每桶生成20个验证多样性
|
||||
python generate_rounds.py --all --per-box 200 --write # 全部模式×档位建箱写库(box_id从1,每箱总回合200)
|
||||
python generate_rounds.py --all --alloc config --write # 池子按后台桶配比分配(推荐):每桶 = 配比 ×1.5
|
||||
python generate_rounds.py --box-type normal --bet 1 --per-box 200 --write # 单箱写库
|
||||
"""
|
||||
import argparse
|
||||
import time
|
||||
from collections import Counter
|
||||
|
||||
from olympus.config import MODES, MODE_ORNAMENT_IDS, ORNAMENTS, RTP, AUDIENCES
|
||||
from olympus_simulator import fill_distribution
|
||||
|
||||
_ORN = {o['id']: o for o in ORNAMENTS}
|
||||
ALLOC_K = 1.5 # --alloc config 时,每桶池子 = 配比 × 该系数(留余量给"不重复发")
|
||||
# 每桶池子下限。只对"配比 × ALLOC_K < 下限"的桶起作用,也就是**配比 ≤13 的桶(6.5x 以上)**:
|
||||
# 这些桶条数少、盘面又显眼(有赢奖/大奖动画),是玩家真正会"看着眼熟"的那几个,所以 20 → 60
|
||||
# 把它们的复现间隔拉长 3 倍(实测 10x 桶 5,455 抽 → 16,364 抽、100x 桶 6 万 → 18 万抽),
|
||||
# 而 0x~4x 这些"量大但没人记住"的桶走 ALLOC_K、完全不受影响 —— 池子总量只 +8%。
|
||||
ALLOC_FLOOR = 60 # 每桶池子下限
|
||||
|
||||
# 并行写库用:round_id 按"该箱池子的实际条数"连续切段(池子条数由配比唯一决定,能事前算准,见 planned_rounds);
|
||||
# 步数取决于随机级联、事前算不准,只能给每箱预留一段固定 id 空间。
|
||||
# 注意要按**最重的箱**留:buy_fs 每箱 ~84,000 步(每局都是 15~30 旋的 FS),normal/ante 只有 ~1.1w 步(大多 1~2 步的基础旋)。
|
||||
BOX_STEP_BAND = 200_000
|
||||
|
||||
|
||||
def read_config_counts(box_id):
|
||||
"""读该箱现有桶配比(各身份取最大值)。没有配置时返回 {}。"""
|
||||
import olympus_db as db
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('''SELECT ornament_id, MAX(count) FROM game_olympus_box_ornament
|
||||
WHERE box_id=%s GROUP BY ornament_id''', (box_id,))
|
||||
return {int(a): int(b) for a, b in cur.fetchall()}
|
||||
|
||||
|
||||
def build_counts_from_config(mode_code, box_id, k=ALLOC_K):
|
||||
"""按后台桶配比分配池子:每桶回合数 = max(下限, 配比 × k)。
|
||||
池子 ≥ 配比 → 一个 cycle 内每个回合最多发一次(配合 Java 侧"不重复发")。"""
|
||||
cfg = read_config_counts(box_id)
|
||||
counts = {}
|
||||
for i in MODE_ORNAMENT_IDS[mode_code]:
|
||||
c = cfg.get(i, 0)
|
||||
if c > 0:
|
||||
counts[i] = max(ALLOC_FLOOR, int(round(c * k)))
|
||||
return counts
|
||||
|
||||
|
||||
def build_counts(mode_code, per_bucket):
|
||||
"""每个可用桶生成相同数量的回合库存,后台再按需配比"""
|
||||
return {i: per_bucket for i in MODE_ORNAMENT_IDS[mode_code]}
|
||||
|
||||
|
||||
def build_counts_total(mode_code, total):
|
||||
"""每箱固定总回合数,跨该模式可用桶均分(余数分给靠前的桶),保证每桶≥1"""
|
||||
ids = list(MODE_ORNAMENT_IDS[mode_code])
|
||||
n = len(ids)
|
||||
base, rem = divmod(total, n)
|
||||
return {i: base + (1 if idx < rem else 0) for idx, i in enumerate(ids)}
|
||||
|
||||
|
||||
def seq_box_id(mode_code, bet):
|
||||
"""全局顺序 box_id(从 1 开始):按模式顺序×档位顺序编号"""
|
||||
idx = 0
|
||||
for mc, mode in MODES.items():
|
||||
for b in mode.base_bets:
|
||||
idx += 1
|
||||
if mc == mode_code and b == bet:
|
||||
return idx
|
||||
return idx
|
||||
|
||||
|
||||
def report(mode_code, counts, rounds):
|
||||
total_target = sum(counts.values())
|
||||
dist = Counter(rd['ornament_id'] for rd, _ in rounds)
|
||||
print(f'[{mode_code}] rounds={len(rounds)}/{total_target}')
|
||||
for i in sorted(counts):
|
||||
hits = [rd for rd, _ in rounds if rd['ornament_id'] == i]
|
||||
if hits:
|
||||
muls = [rd['actual_mul'] for rd in hits]
|
||||
casc = [rd['total_cascades'] for rd in hits]
|
||||
fs = sum(1 for rd in hits if rd['free_spin_count'] > 0)
|
||||
extra = (f'倍率{min(muls):.2f}~{max(muls):.2f} '
|
||||
f'步数{min(casc)}~{max(casc)} FS回合{fs}')
|
||||
else:
|
||||
extra = ''
|
||||
print(f' 桶{i:>2} {_ORN[i]["name"]:>6}: {dist.get(i, 0):>5}/{counts[i]:>5} {extra}')
|
||||
capped = sum(1 for _, ss in rounds if any(s['win_capped'] for s in ss))
|
||||
overmax = sum(1 for rd, _ in rounds if rd['actual_mul'] > 1000)
|
||||
print(f' 封顶截断={capped} 超1000={overmax}')
|
||||
|
||||
|
||||
def dry_run(per_bucket=20):
|
||||
for mode_code in MODES:
|
||||
counts = build_counts(mode_code, per_bucket)
|
||||
t0 = time.time()
|
||||
rounds, _ = fill_distribution({'box_id': 0, 'box_type': mode_code}, counts)
|
||||
report(mode_code, counts, rounds)
|
||||
print(f' 耗时 {time.time() - t0:.1f}s\n')
|
||||
|
||||
|
||||
def derive_cycle_rounds(box_id, fixed_sum, default_cr=0.15):
|
||||
"""一轮抽多少抽 N = round(固定池 /(1-收敛比例)),对齐 Java deriveTotalRounds。
|
||||
box.total_ornaments 的语义是"一轮总抽数",不是池子大小(抽完补池时 Java 也会回写这个值)。"""
|
||||
import olympus_db as db
|
||||
cr = default_cr
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT rtp_converge_ratio FROM game_olympus_box WHERE box_id=%s', (box_id,))
|
||||
row = cur.fetchone()
|
||||
if row and row[0] is not None:
|
||||
cr = float(row[0])
|
||||
cr = min(max(cr, 0.0), 0.9)
|
||||
return int(round(fixed_sum / (1.0 - cr)))
|
||||
|
||||
|
||||
def write_box(mode_code, bet, box_id, per_box, alloc='flat', round_base=None, step_base=None, planned=None):
|
||||
import olympus_db as db
|
||||
mode = MODES[mode_code]
|
||||
if bet not in mode.base_bets:
|
||||
raise SystemExit(f'模式 {mode_code} 不支持档位 {bet},可选: {mode.base_bets}')
|
||||
price = float(mode.price_for(bet))
|
||||
counts = build_counts_from_config(mode_code, box_id) if alloc == 'config' else {}
|
||||
if not counts:
|
||||
counts = build_counts_total(mode_code, per_box)
|
||||
total_rounds = sum(counts.values())
|
||||
config_counts = read_config_counts(box_id) or dict(counts)
|
||||
box = {'box_id': box_id, 'box_type': mode_code, 'bet': bet,
|
||||
'name': f'{mode.name}-bet{bet}', 'price': price,
|
||||
'total_ornaments': derive_cycle_rounds(box_id, sum(config_counts.values())),
|
||||
'rtp': RTP}
|
||||
print(f'生成 box={box_id} {box["name"]} price={price} '
|
||||
f'分配方式={alloc} 池子{total_rounds}条 / 一轮{box["total_ornaments"]}抽 ...')
|
||||
t0 = time.time()
|
||||
rounds, gen = fill_distribution(box, counts)
|
||||
report(mode_code, counts, rounds)
|
||||
if planned is not None and len(rounds) != planned:
|
||||
raise SystemExit(f'box={box_id}: 实际生成 {len(rounds)} 条 ≠ 预留 id 段 {planned} 条,'
|
||||
f'id 段会错位 —— 重跑一次(配比刚改过就先 --clear)')
|
||||
|
||||
round_base = db.get_max_round_id() if round_base is None else round_base
|
||||
step_base = db.get_max_step_id() if step_base is None else step_base
|
||||
rounds_data, steps_data = [], []
|
||||
rid, sid = round_base, step_base
|
||||
for rd, steps in rounds:
|
||||
rid += 1
|
||||
rd['round_id'] = rid
|
||||
rounds_data.append(rd)
|
||||
for s in steps:
|
||||
sid += 1
|
||||
s['id'] = sid
|
||||
s['round_id'] = rid
|
||||
steps_data.append(s)
|
||||
if len(steps_data) > BOX_STEP_BAND:
|
||||
raise SystemExit(f'box={box_id}: {len(steps_data)} 步超出每箱预留段 {BOX_STEP_BAND},'
|
||||
f'把 BOX_STEP_BAND 调大再跑')
|
||||
|
||||
db.insert_box(box)
|
||||
# box_ornament:已有后台配比则原样写回(池子形状 ≠ 配比,别把配比覆盖成池子形状);
|
||||
# 首次建箱才用实际生成量做种子
|
||||
keep = read_config_counts(box_id) or dict(gen)
|
||||
db.write_box_distributions(box_id, {aud: dict(keep) for aud in AUDIENCES})
|
||||
db.write_rounds_and_steps(box_id, rounds_data, steps_data)
|
||||
print(f'写库完成: {len(rounds_data)} rounds / {len(steps_data)} steps, '
|
||||
f'耗时 {time.time() - t0:.1f}s\n')
|
||||
|
||||
|
||||
def all_boxes():
|
||||
"""(box_id, mode_code, bet) 顺序编号,与旧 --all 完全一致(1..30)"""
|
||||
boxes = []
|
||||
box_id = 0
|
||||
for mode_code, mode in MODES.items():
|
||||
for bet in mode.base_bets:
|
||||
box_id += 1
|
||||
boxes.append((box_id, mode_code, bet))
|
||||
return boxes
|
||||
|
||||
|
||||
def _box_task(task):
|
||||
"""worker 入口:跑一箱(id 段由任务自带,互不重叠)"""
|
||||
box_id, mode_code, bet, per_box, alloc, r_base, s_base, planned = task
|
||||
write_box(mode_code, bet, box_id, per_box, alloc, r_base, s_base, planned)
|
||||
return box_id
|
||||
|
||||
|
||||
def planned_rounds(mode_code, box_id, per_box, alloc):
|
||||
"""这箱这一轮会生成多少条回合:池子条数 = Σ max(ALLOC_FLOOR, 配比×ALLOC_K),由配比唯一决定。
|
||||
用它给每箱切一段**连续**的 round_id(并行写库不会撞主键,id 也不会有空洞)。"""
|
||||
counts = build_counts_from_config(mode_code, box_id) if alloc == 'config' else {}
|
||||
if not counts:
|
||||
counts = build_counts_total(mode_code, per_box)
|
||||
return sum(counts.values())
|
||||
|
||||
|
||||
def clear_pool_tables():
|
||||
"""TRUNCATE 回合池两张表:清空全部 30 箱的旧回合,id 从 1 重排。
|
||||
注意 game_olympus_record(战绩)里的 round_id 会因此指向新池子里的同号回合 —— 战绩金额本身不受影响。"""
|
||||
import olympus_db as db
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('TRUNCATE TABLE game_olympus_step')
|
||||
cur.execute('TRUNCATE TABLE game_olympus_round')
|
||||
print('已 TRUNCATE game_olympus_step / game_olympus_round(旧池子清空,round_id 从 1 重排)')
|
||||
|
||||
|
||||
def _existing_rounds(box_id):
|
||||
import olympus_db as db
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT COUNT(*) FROM game_olympus_round WHERE box_id=%s', (box_id,))
|
||||
return int(cur.fetchone()[0])
|
||||
|
||||
|
||||
def run_all(alloc, per_box, workers, clear=False, resume=False):
|
||||
"""--all:顺序(workers=1)或按箱并行写库。
|
||||
生成是纯 CPU 活(多线程没用,要进程);各箱 round_id 段连续不重叠,所以并行也不会撞主键。
|
||||
--resume:跳过已经写好的箱(条数 == 计划条数),只补剩下的 —— id 段仍按整轮 30 箱排,所以布局和整轮跑一致。
|
||||
进程数:本机只有 4 个物理核(8 逻辑),而且 VS Code/IDEA/Docker 常驻,建议 1~2。"""
|
||||
import olympus_db as db
|
||||
if clear:
|
||||
clear_pool_tables()
|
||||
cursor, step_base0 = 0, 0
|
||||
else:
|
||||
cursor, step_base0 = db.get_max_round_id(), db.get_max_step_id()
|
||||
tasks = []
|
||||
for bid, mc, bet in all_boxes():
|
||||
n = planned_rounds(mc, bid, per_box, alloc)
|
||||
tasks.append((bid, mc, bet, per_box, alloc,
|
||||
cursor, step_base0 + bid * BOX_STEP_BAND, n))
|
||||
cursor += n
|
||||
if resume:
|
||||
left = [t for t in tasks if _existing_rounds(t[0]) != t[7]]
|
||||
print(f'--resume:已写好 {len(tasks) - len(left)} 箱,待生成 {len(left)} 箱')
|
||||
tasks = left
|
||||
if not tasks:
|
||||
print('没有需要生成的箱')
|
||||
return
|
||||
print(f'共 {len(tasks)} 箱,round_id 段 {tasks[0][5] + 1} ~ {cursor}(连续、不重叠)')
|
||||
if workers <= 1:
|
||||
t0 = time.time()
|
||||
for done, t in enumerate(tasks, 1):
|
||||
_box_task(t)
|
||||
print(f'--- [{done}/{len(tasks)}] box={t[0]} 完成 累计 {time.time() - t0:.1f}s')
|
||||
return
|
||||
import multiprocessing as mp
|
||||
t0 = time.time()
|
||||
print(f'并行 {workers} 进程 ...')
|
||||
with mp.Pool(processes=workers) as pool:
|
||||
for done, bid in enumerate(pool.imap_unordered(_box_task, tasks), 1):
|
||||
print(f'--- [{done}/{len(tasks)}] box={bid} 完成 累计 {time.time() - t0:.1f}s')
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--box-type', choices=list(MODES), help='模式')
|
||||
ap.add_argument('--bet', type=int, help='基础下注档位')
|
||||
ap.add_argument('--per-bucket', type=int, default=20, help='干跑每桶回合数(仅 dry-run 用)')
|
||||
ap.add_argument('--per-box', type=int, default=200, help='每箱总回合数(--alloc flat 用,跨桶均分)')
|
||||
ap.add_argument('--alloc', choices=['flat', 'config'], default='flat',
|
||||
help='flat=跨桶均分(旧行为);config=按后台桶配比×1.5 分配(池子≥配比)')
|
||||
ap.add_argument('--write', action='store_true', help='写入数据库')
|
||||
ap.add_argument('--all', action='store_true', help='全部模式×档位,box_id 从 1 顺序编号')
|
||||
ap.add_argument('--workers', type=int, default=1,
|
||||
help='并行进程数(生成是纯 CPU 活,多线程没用;建议 ≤3,DB 连接数限制)')
|
||||
ap.add_argument('--clear', action='store_true',
|
||||
help='先 TRUNCATE 回合池两张表(全部箱),round_id 从 1 重排;只配合 --all 用')
|
||||
ap.add_argument('--resume', action='store_true',
|
||||
help='只补还没写好的箱(跳过已写完的),id 段仍按整轮 30 箱排')
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.clear and not args.all:
|
||||
raise SystemExit('--clear 会清空全部箱的回合池,只配合 --all 用')
|
||||
if not args.write:
|
||||
dry_run(min(args.per_bucket, 20))
|
||||
return
|
||||
if args.all:
|
||||
run_all(args.alloc, args.per_box, args.workers, args.clear, args.resume)
|
||||
elif args.box_type and args.bet:
|
||||
write_box(args.box_type, args.bet, seq_box_id(args.box_type, args.bet),
|
||||
args.per_box, args.alloc)
|
||||
else:
|
||||
raise SystemExit('--write 需要 --box-type + --bet,或 --all')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Olympus 电子游戏规则核心 (0.1.0)。"""
|
||||
|
||||
from .config import (
|
||||
SYMBOL_RULES,
|
||||
SCATTER_CODE,
|
||||
SCATTER_PAYOUTS,
|
||||
MODES,
|
||||
DEFAULT_MODE,
|
||||
MAX_WIN_MULTIPLIER,
|
||||
GameMode,
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
# config
|
||||
"SYMBOL_RULES",
|
||||
"SCATTER_CODE",
|
||||
"SCATTER_PAYOUTS",
|
||||
"MODES",
|
||||
"DEFAULT_MODE",
|
||||
"MAX_WIN_MULTIPLIER",
|
||||
"GameMode",
|
||||
# engine
|
||||
"OlympusEngine",
|
||||
"RuleViolation",
|
||||
# models
|
||||
"Bomb",
|
||||
"CascadeResult",
|
||||
"Elimination",
|
||||
"SessionState",
|
||||
"SpinResult",
|
||||
]
|
||||
|
||||
from .engine import OlympusEngine, RuleViolation
|
||||
from .models import Bomb, CascadeResult, Elimination, SessionState, SpinResult
|
||||
@@ -0,0 +1,311 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Mapping, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SymbolRule:
|
||||
code: str
|
||||
name: str
|
||||
payouts: Mapping[int, Decimal]
|
||||
|
||||
def payout_for(self, count: int) -> Decimal:
|
||||
for threshold in sorted(self.payouts, reverse=True):
|
||||
if count >= threshold:
|
||||
return self.payouts[threshold]
|
||||
return Decimal("0")
|
||||
|
||||
|
||||
# 赔付倍数以 1 倍下注为基准,数值对齐官方赔付表(8-9 / 10-11 / 12-30 三档)
|
||||
SYMBOL_RULES = {
|
||||
"CROWN": SymbolRule("CROWN", "皇冠", {12: Decimal("50"), 10: Decimal("25"), 8: Decimal("10")}),
|
||||
"HOURGLASS": SymbolRule("HOURGLASS", "沙漏", {12: Decimal("25"), 10: Decimal("10"), 8: Decimal("2.5")}),
|
||||
"RING": SymbolRule("RING", "环", {12: Decimal("15"), 10: Decimal("5"), 8: Decimal("2")}),
|
||||
"CHALICE": SymbolRule("CHALICE", "杯赛", {12: Decimal("12"), 10: Decimal("2"), 8: Decimal("1.5")}),
|
||||
"RED_GEM": SymbolRule("RED_GEM", "红宝石", {12: Decimal("10"), 10: Decimal("1.5"), 8: Decimal("1")}),
|
||||
"PURPLE_GEM": SymbolRule("PURPLE_GEM", "紫宝石", {12: Decimal("8"), 10: Decimal("1.2"), 8: Decimal("0.8")}),
|
||||
"YELLOW_GEM": SymbolRule("YELLOW_GEM", "黄宝石", {12: Decimal("5"), 10: Decimal("1"), 8: Decimal("0.5")}),
|
||||
"GREEN_GEM": SymbolRule("GREEN_GEM", "绿宝石", {12: Decimal("4"), 10: Decimal("0.9"), 8: Decimal("0.4")}),
|
||||
"BLUE_GEM": SymbolRule("BLUE_GEM", "蓝宝石", {12: Decimal("2"), 10: Decimal("0.75"), 8: Decimal("0.25")}),
|
||||
}
|
||||
|
||||
SCATTER_CODE = "SCATTER"
|
||||
# 6/5/4 个 SCATTER 的赔付倍数(1 倍下注基准,与皇冠系列量表同源)
|
||||
SCATTER_PAYOUTS = {
|
||||
6: Decimal("100"),
|
||||
5: Decimal("5"),
|
||||
4: Decimal("3"),
|
||||
}
|
||||
SCATTER_FREE_SPIN_TRIGGER = 4 # 基础盘 4+ SCATTER 触发免费旋转
|
||||
SCATTER_RETRIGGER_TRIGGER = 3 # 免费旋转中 3+ SCATTER 触发重触发
|
||||
FREE_SPINS_AWARDED = 15 # 首次触发赠送次数
|
||||
FREE_SPIN_RETRIGGER_COUNT = 5 # 每次重触发追加次数
|
||||
|
||||
MULTIPLIER_VALUES = frozenset({
|
||||
2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 50, 100, 250, 500, 1000,
|
||||
})
|
||||
|
||||
MIN_WIN_COUNT = 8
|
||||
MAX_SYMBOL_COUNT = 30
|
||||
|
||||
# 单局最高赢奖封顶:1000 倍基础下注(最高 1000x 饰品)
|
||||
MAX_WIN_MULTIPLIER = Decimal("1000")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 游戏模式:互斥规则
|
||||
# - 底注(ante)开启时禁用购买免费旋转;购买免费旋转(buy_fs)时禁用底注效果
|
||||
# - 只有一个购买免费旋转档(无 super 模式)
|
||||
# ============================================================
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameMode:
|
||||
code: str
|
||||
name: str
|
||||
cost_multiplier: int # 实际扣费 = 基础下注 × cost_multiplier
|
||||
base_bets: Tuple[int, ...] # 可选基础下注档位
|
||||
ante_boost: bool # 底注效果(SCATTER 概率提升)
|
||||
guaranteed_free_spins: bool # 保证直接进入免费旋转
|
||||
can_buy_free_spins: bool # 是否允许购买免费旋转
|
||||
|
||||
def price_for(self, base_bet: int) -> Decimal:
|
||||
if base_bet not in self.base_bets:
|
||||
raise ValueError(f"模式 {self.code} 不支持下注档位 {base_bet}")
|
||||
return Decimal(base_bet) * self.cost_multiplier
|
||||
|
||||
|
||||
_NORMAL_BETS = (1, 3, 5, 10, 20, 30, 50, 100, 200, 300)
|
||||
_BUY_FS_BETS = _NORMAL_BETS # 10 档,实际价格 50 ~ 15000
|
||||
|
||||
MODES = {
|
||||
# 普通旋转:实际扣费 = 1 3 5 10 20 30 50 100 200 300
|
||||
"normal": GameMode(
|
||||
code="normal", name="普通旋转", cost_multiplier=1,
|
||||
base_bets=_NORMAL_BETS,
|
||||
ante_boost=False, guaranteed_free_spins=False, can_buy_free_spins=True,
|
||||
),
|
||||
# 底注:实际扣费 = 2 6 10 20 40 60 100 200 400 600;禁止购买免费旋转
|
||||
"ante": GameMode(
|
||||
code="ante", name="底注", cost_multiplier=2,
|
||||
base_bets=_NORMAL_BETS,
|
||||
ante_boost=True, guaranteed_free_spins=False, can_buy_free_spins=False,
|
||||
),
|
||||
# 购买免费旋转:实际扣费 = 50 150 250 500 1000 1500 2500 5000 10000 15000;无底注效果
|
||||
# 售价 50 倍(原 20 倍):局均赔付 = 售价 × RTP = 40 倍底注 → 后台一键重算会把配比压到
|
||||
# 25x/40x/60x 桶(FS 预算 22~57),每局才演得出 6~10 次消除 + 4~6 次炸弹累计;
|
||||
# 20 倍时局均只有 16 倍,配比被迫压在 10x/16x 桶(预算 7~13),动画"钱不够"。
|
||||
"buy_fs": GameMode(
|
||||
code="buy_fs", name="购买免费旋转", cost_multiplier=50,
|
||||
base_bets=_BUY_FS_BETS,
|
||||
ante_boost=False, guaranteed_free_spins=True, can_buy_free_spins=False,
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_MODE = "normal"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 饰品桶定义(DB 写入用):最高 1000x,对齐封顶规则
|
||||
# 设计原则:区间连续无缝覆盖 0~1370(封顶前理论值域);
|
||||
# expect_mul 严格等于 (range_low + range_high) / 2;
|
||||
# 低倍段密集(高频小奖)、高倍段递宽。
|
||||
# 注:顶桶 id=17 区间 630~1370(中点=期望=1000),但实际发放受封顶约束:
|
||||
# 超过 1000 倍的部分不再给奖,一律按 1000 倍发放(引擎 MAX_WIN_MULTIPLIER 截断)。
|
||||
# ============================================================
|
||||
ORNAMENTS = [
|
||||
{'id': 1, 'name': '0x', 'expect_mul': 0.0, 'range_low': 0.00, 'range_high': 0.00},
|
||||
{'id': 2, 'name': '0.5x', 'expect_mul': 0.5, 'range_low': 0.01, 'range_high': 1.00},
|
||||
{'id': 3, 'name': '1.5x', 'expect_mul': 1.5, 'range_low': 1.00, 'range_high': 2.00},
|
||||
{'id': 4, 'name': '2.5x', 'expect_mul': 2.5, 'range_low': 2.00, 'range_high': 3.00},
|
||||
{'id': 5, 'name': '4x', 'expect_mul': 4.0, 'range_low': 3.00, 'range_high': 5.00},
|
||||
{'id': 6, 'name': '6.5x', 'expect_mul': 6.5, 'range_low': 5.00, 'range_high': 8.00},
|
||||
{'id': 7, 'name': '10x', 'expect_mul': 10.0, 'range_low': 8.00, 'range_high': 12.00},
|
||||
{'id': 8, 'name': '16x', 'expect_mul': 16.0, 'range_low': 12.00, 'range_high': 20.00},
|
||||
{'id': 9, 'name': '25x', 'expect_mul': 25.0, 'range_low': 20.00, 'range_high': 30.00},
|
||||
{'id': 10, 'name': '40x', 'expect_mul': 40.0, 'range_low': 30.00, 'range_high': 50.00},
|
||||
{'id': 11, 'name': '60x', 'expect_mul': 60.0, 'range_low': 50.00, 'range_high': 70.00},
|
||||
{'id': 12, 'name': '100x', 'expect_mul': 100.0, 'range_low': 70.00, 'range_high': 130.00},
|
||||
{'id': 13, 'name': '150x', 'expect_mul': 150.0, 'range_low': 130.00, 'range_high': 170.00},
|
||||
{'id': 14, 'name': '200x', 'expect_mul': 200.0, 'range_low': 170.00, 'range_high': 230.00},
|
||||
{'id': 15, 'name': '300x', 'expect_mul': 300.0, 'range_low': 230.00, 'range_high': 370.00},
|
||||
{'id': 16, 'name': '500x', 'expect_mul': 500.0, 'range_low': 370.00, 'range_high': 630.00},
|
||||
{'id': 17, 'name': '1000x', 'expect_mul': 1000.0, 'range_low': 630.00, 'range_high': 1370.00},
|
||||
]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 回合生成器参数
|
||||
# ============================================================
|
||||
GRID_ROWS = 5
|
||||
GRID_COLS = 6
|
||||
GRID_SIZE = GRID_ROWS * GRID_COLS # 30
|
||||
|
||||
# 短码映射(网格/步骤 JSON 存储用,对齐 sweet 风格)
|
||||
SYMBOL_SHORT = {
|
||||
'CROWN': 'A', 'HOURGLASS': 'B', 'RING': 'C', 'CHALICE': 'D', 'RED_GEM': 'E',
|
||||
'PURPLE_GEM': 'F', 'YELLOW_GEM': 'G', 'GREEN_GEM': 'H', 'BLUE_GEM': 'I',
|
||||
}
|
||||
SHORT_TO_SYMBOL = {v: k for k, v in SYMBOL_SHORT.items()}
|
||||
SCATTER_SHORT = 'S'
|
||||
BOMB_SHORT = 'M'
|
||||
|
||||
# 普通符号随机权重(赔率越高权重越低)
|
||||
SYMBOL_WEIGHTS = {'A': 5, 'B': 6, 'C': 7, 'D': 8, 'E': 9, 'F': 10, 'G': 10, 'H': 10, 'I': 12}
|
||||
|
||||
# 炸弹(乘数符号)参数
|
||||
BOMB_CELL_PROBABILITY = 0.04 # 每格出现炸弹概率 ≈1个/屏
|
||||
BOMB_VALUES = sorted(MULTIPLIER_VALUES)
|
||||
BOMB_WEIGHT_POWER = 0.5 # 值越大权重越低: w = 1/v^power
|
||||
|
||||
# 炸弹品质(按乘数值分 5 档,对应绿/蓝/紫/红/彩虹 5 种球体)
|
||||
BOMB_QUALITIES = [
|
||||
# (quality_code, 名称, 等级, 包含乘数值)
|
||||
('GREEN', '绿色炸弹', 1, (2, 3, 4, 5, 6)),
|
||||
('BLUE', '蓝色炸弹', 2, (8, 10, 12, 15)),
|
||||
('PURPLE', '紫色炸弹', 3, (20, 25, 50)),
|
||||
('RED', '红色炸弹', 4, (100, 250)),
|
||||
('RAINBOW', '彩虹炸弹', 5, (500, 1000)),
|
||||
]
|
||||
# value -> quality_code 快速查表
|
||||
BOMB_VALUE_QUALITY = {v: code for code, _, _, vals in BOMB_QUALITIES for v in vals}
|
||||
|
||||
|
||||
def bomb_quality(value: int) -> str:
|
||||
"""炸弹乘数值 → 品质编码(GREEN/BLUE/PURPLE/RED/RAINBOW)"""
|
||||
return BOMB_VALUE_QUALITY[value]
|
||||
|
||||
# 免费旋转触发/散布(底注概率翻倍,互斥规则:buy_fs 保证触发)
|
||||
BASE_FS_TRIGGER_PROB = {'normal': 0.005, 'ante': 0.010}
|
||||
BASE_NEARMISS_SCATTER = {
|
||||
'normal': {0: 0.88, 1: 0.08, 2: 0.03, 3: 0.01},
|
||||
'ante': {0: 0.74, 1: 0.16, 2: 0.07, 3: 0.03},
|
||||
}
|
||||
FS_TRIGGER_SCATTER = {4: 0.80, 5: 0.15, 6: 0.05} # 触发盘落 S 数
|
||||
FREESPIN_SCATTER = { # FS 每旋落 S 数(≥3 → +5)
|
||||
'default': {0: 0.70, 1: 0.15, 2: 0.07, 3: 0.05, 4: 0.025, 5: 0.005},
|
||||
'buy_fs': {0: 0.78, 1: 0.13, 2: 0.06, 3: 0.02, 4: 0.008, 5: 0.002},
|
||||
}
|
||||
MAX_FREE_SPINS = 30 # 重触发总次数上限
|
||||
|
||||
# ============================================================
|
||||
# 免费旋转"每旋保底消除"(仅 buy_fs)
|
||||
# 动机:15 旋原来 86.8% 的旋完全空转(45% 强制空转 + 自然填充 82.9% 凑不出 8 连)
|
||||
# 做法:本旋没消成时按概率补一组最小档消除(8 连低价值符号 = 0.25 倍底注),消完再按概率续链
|
||||
# 预算:补桶时按桶目标反推本局 FS 预算 —— 按剩余旋数自平衡,花完即停
|
||||
# 注:normal/ante 的自然触发盘不走这套(仍按原 45% 空转 + 自然),避免打穿 0x~2.5x 低倍桶
|
||||
# ============================================================
|
||||
FS_SPIN_WIN_PROB = 0.60 # 自然阶段:每旋"至少消一波"的概率
|
||||
# 有预算时(buy_fs / 高倍桶补桶)不再按"剩余预算÷剩余旋数"算每旋概率 —— 扣预算扣的是**已被累计
|
||||
# 倍数放大**的赢额,累计器一涨几旋就把预算烧穿 → 后半局 p=0 一直空转到结束(实测买 FS 有 88% 的局
|
||||
# 出现 ≥4 旋连空、51.7% 的局尾巴一直空转)。改成:一局能买几次消除由预算反推
|
||||
# (k ≈ √(预算 / FS_GUARANTEE_COST)),每次消除后**重抽下一次的位置**(间隔随机,见
|
||||
# `olympus_simulator._fs_next_target`)—— 整局消除次数不变,但不再"每 4 旋必消"。
|
||||
FS_GUARANTEE_COST = 0.45 # 每多一次"保底消除+炸弹累计"的边际赔付(实测 ≈0.45×k)
|
||||
# 随机间隔的兜底:连续空转越久,越可能被强制补一次保底消除(**软**上限,不是硬闸)——
|
||||
# 硬上限(到第 N 旋必消)会留下新的固定天花板(实测硬顶 6 时有 42% 的局"最长连空正好 6"),
|
||||
# 所以按概率递增:dry=5/6 时 0.5/0.85,dry≥8 必补。配额提前用完时靠它兜住长空尾。
|
||||
# 实测(box21 整池):最长连空 2~8 旋散开、单值最高 42%(等距版是 87% 都卡在 4),≥7 旋占 2.5%。
|
||||
FS_DRY_FORCE_PROB = {5: 0.50, 6: 0.85}
|
||||
FS_DRY_FORCE_HARD = 8 # 到这个旋数无条件补(概率表兜不到时)
|
||||
# 连消概率:越低,同一份预算能覆盖的"有消除的旋"越多(0.45 时预算集中在少数旋上,
|
||||
# 一局只剩 1~2 旋有消除;0.10 时同样的钱能铺到 4~10 旋)
|
||||
FS_CASCADE_CONTINUE_PROB = 0.10
|
||||
FS_CASCADE_MAX = 5 # 单旋最大消除波数
|
||||
FS_GUARANTEE_COMBOS = ( # 保底消除组合 (符号, 个数, 赔付倍数@1倍底注),最小档为主
|
||||
('I', 8, 0.25), ('I', 8, 0.25), ('H', 8, 0.40), ('G', 8, 0.50),
|
||||
)
|
||||
|
||||
# 免费旋转里的炸弹(buy_fs):**每一旋都可能出现、每屏最多 1 颗**。
|
||||
# ① 每一旋都可能出现:前面几旋的消除盘面上也看得见炸弹,倍数从第 1~2 旋就开始涨;
|
||||
# ② 每屏最多 1 颗:一屏堆 2~4 颗会"一次消除好几颗一起爆",看不出"消一次爆一颗"的节奏;
|
||||
# ③ 炸弹**有大有小**,但按该局的档位配:低倍档(< FS_BIG_BOMB_TARGET)用 2~8 的小球
|
||||
# (累计倍数作用到每一次消除:0.4×2 + 0.5×4 = 2.8 这种滚雪球,大球一合并后面每次消除
|
||||
# 都被放大几十倍,低倍档的预算立刻见底),高倍档(≥100x)用全量 2~1000 —— 所以
|
||||
# "大炸弹"照样存在,只是它出现的那些局自然落在高倍桶。
|
||||
FS_SCREEN_BOMB_PROB = 0.08
|
||||
FS_SMALL_BOMB_MAX = 8
|
||||
FS_BIG_BOMB_TARGET = 100
|
||||
FS_MAX_BOMBS_PER_SPIN = 1
|
||||
|
||||
# "只能靠免费旋转赔出来"的目标倍数下限:normal/ante 的基础旋不出炸弹,最多只能凑到
|
||||
# A12(50) + B10(10) ≈ 60x(自然多组 75x 已属极罕见),所以目标 ≥80x 的桶(100x/150x/200x)
|
||||
# 只能生成"自然触发 FS"的回合来装 —— 这也是这两个模式里玩家能进 FS 的现实来源。
|
||||
FS_ONLY_TARGET_MUL = 80.0
|
||||
|
||||
# 目标 RTP 与各模式可用饰品桶
|
||||
RTP = 0.8
|
||||
MODE_ORNAMENT_IDS = {
|
||||
'normal': list(range(1, 14)), # 0x ~ 150x
|
||||
'ante': list(range(1, 15)), # 0x ~ 200x
|
||||
'buy_fs': list(range(7, 18)), # 10x ~ 1000x(保证进 FS;原 5 起=4x 起,低倍桶预算装不下 FS 动画)
|
||||
}
|
||||
# 桶"实际发出期望"的**回退值**:桶 17 展示 1000x(区间 630~1370),但单局封顶 1000 →
|
||||
# 真正发出去的是 min(原始分, 1000):
|
||||
# · 理论值(假设原始分在 630~1370 均匀分布):E[min(x,1000)] = 907.5
|
||||
# · 实测值(buy_fs 10 箱 × 桶17 全部回合 = 200 个):均值 834.6、中位 821.3、27.5% 触顶 1000
|
||||
# —— 生成端种的大奖目标集中在 1000 附近、不是均匀分布,所以实测低于理论值。
|
||||
# 注意:配比(calcRounds)与后台「目标RTP」现在优先用**该箱池内 AVG(actual_mul)**(见
|
||||
# _recalc_boxes.load_pool_expect / Java loadPoolExpect),这个覆盖值只在"池子里一条回合都没有"
|
||||
# 时兜底 —— 它比名义 1000 诚实(高估约 0.5~1.6 个百分点)。
|
||||
PAYOUT_EXPECT_OVERRIDE = {17: 835.0}
|
||||
|
||||
_ORN_EXPECT = {o['id']: float(o['expect_mul']) for o in ORNAMENTS}
|
||||
|
||||
|
||||
def payout_expect(orn_id: int) -> float:
|
||||
"""桶的"实际发出期望"回退值:受封顶影响的桶用实测覆盖值,其余用名义 expect_mul。
|
||||
|
||||
用于:池子里还没有该桶回合时的兜底、以及模拟/报表里的名义口径对比。
|
||||
**抽回合时的 payoutCap 过滤仍用名义值**(Java OlympusRoundPicker 读 DB 的 expect_mul,
|
||||
偏保守,保留安全余量)。"""
|
||||
return PAYOUT_EXPECT_OVERRIDE.get(orn_id, _ORN_EXPECT.get(orn_id, 0.0))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 多身份爆率(audience):每种身份一套独立的饰品桶数量分布,实现差异化 RTP。
|
||||
# 回合库存池(round/step)完全共享,仅“抽哪个桶”的概率按身份区分。
|
||||
# 与现有宝箱 tt_user 对齐:user_type 01主播 / 02普通 / 03机器人;is_blank_account=1 白号。
|
||||
# 身份解析优先级(Java 开局):白号最高优先,压过 user_type:
|
||||
# is_blank_account==1 → white;user_type=01 → anchor;03 → robot;其余 → common。
|
||||
# 白号池(white):独立 count/used,抽取只递减自己的 used、独立补桶,
|
||||
# 永不消耗 common/anchor/robot 库存;白号 bet/win 不计水位、赢奖不进真实经济。
|
||||
# 机器人暂不细分幸运/非幸运档(后续视 2v2 需求再扩展 robot_unlucky)。
|
||||
# ============================================================
|
||||
AUDIENCE_COMMON = 'common' # 普通用户(对齐 user_type=02 及招商/卡商/超管等其他)
|
||||
AUDIENCE_ANCHOR = 'anchor' # 主播(user_type=01)
|
||||
AUDIENCE_ROBOT = 'robot' # 机器人(user_type=03)
|
||||
AUDIENCE_WHITE = 'white' # 白号(is_blank_account=1,压过 user_type)
|
||||
AUDIENCES = (AUDIENCE_COMMON, AUDIENCE_ANCHOR, AUDIENCE_ROBOT, AUDIENCE_WHITE)
|
||||
DEFAULT_AUDIENCE = AUDIENCE_COMMON
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 爆奖动画(小/中/大 3 档):**只在免费旋转里判档**,按"单旋赢额"独立判档,
|
||||
# 达标即在该旋末帧盖章,同档一局可多次;normal/ante 的基础旋一律 null(不播大奖动画)。
|
||||
# 两种情况两套阈值(单位:单旋赢额 ÷ 基础下注):
|
||||
# ① 自然免费旋转(normal/ante 摇出 4+ S 进入):5 / 25 / 100
|
||||
# 两个模式的 FS 预算与旋数完全一致 → 共用一套;实测 FS 内约 15% 的旋 ≥5、
|
||||
# 约 4% 的旋 ≥25、约 1.7% 的旋 ≥100(约 1/4 的 FS 局能见到"大")
|
||||
# ② 购买免费旋转(buy_fs):20 / 60 / 200
|
||||
# 买 FS 的赢额分散在 15+ 旋里,单旋阈值整体下调;实测约 3.5% 的旋 ≥20、
|
||||
# 约 0.9% 的旋 ≥60、约 0.1% 的旋 ≥200(约 2% 的买 FS 局能见到"大")
|
||||
# 编码 small < middle < big:前端按这三个值播小/中/大动画,null 表示不播
|
||||
# ============================================================
|
||||
BIGWIN_LEVELS = ('small', 'middle', 'big')
|
||||
BIGWIN_TIERS = {
|
||||
'normal': (5, 25, 100),
|
||||
'ante': (5, 25, 100), # 与 normal 同一口径:都是"自然触发的 FS",整局规模相同
|
||||
'buy_fs': (20, 60, 200),
|
||||
}
|
||||
|
||||
|
||||
def bigwin_level(mode_code: str, spin_mul: float):
|
||||
"""单旋赢额(基础注倍数)→ 爆奖档位编码 small/middle/big;未达最低档返回 None"""
|
||||
tiers = BIGWIN_TIERS[mode_code]
|
||||
level = None
|
||||
for name, threshold in zip(BIGWIN_LEVELS, tiers):
|
||||
if spin_mul >= threshold:
|
||||
level = name
|
||||
return level
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from decimal import Decimal
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
|
||||
from .config import (
|
||||
DEFAULT_MODE,
|
||||
MAX_SYMBOL_COUNT,
|
||||
MAX_WIN_MULTIPLIER,
|
||||
MIN_WIN_COUNT,
|
||||
MODES,
|
||||
MULTIPLIER_VALUES,
|
||||
SYMBOL_RULES,
|
||||
GameMode,
|
||||
)
|
||||
from .models import Bomb, CascadeResult, Elimination, SessionState, SpinResult
|
||||
|
||||
|
||||
class RuleViolation(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class OlympusEngine:
|
||||
def __init__(
|
||||
self,
|
||||
bet: Decimal | int | str = Decimal("1"),
|
||||
mode: str = DEFAULT_MODE,
|
||||
) -> None:
|
||||
if mode not in MODES:
|
||||
raise RuleViolation(f"未知模式: {mode},可选: {sorted(MODES)}")
|
||||
self.mode: GameMode = MODES[mode]
|
||||
self.bet = Decimal(str(bet))
|
||||
if self.bet <= 0:
|
||||
raise RuleViolation("bet 必须大于 0")
|
||||
# 档位校验:整数档位必须在模式允许列表内(非整数 bet 仅用于单测/模拟,不校验)
|
||||
if self.bet == self.bet.to_integral_value() and int(self.bet) not in self.mode.base_bets:
|
||||
raise RuleViolation(
|
||||
f"模式 {self.mode.code} 不支持下注档位 {int(self.bet)},可选: {self.mode.base_bets}"
|
||||
)
|
||||
self.cost = self.bet * self.mode.cost_multiplier # 实际扣费
|
||||
self.max_win = self.bet * MAX_WIN_MULTIPLIER # 单局封顶 1000x 基础下注
|
||||
self.state = SessionState()
|
||||
self._cascades: list[CascadeResult] | None = None
|
||||
self._symbol_counts: Counter[str] = Counter()
|
||||
self._spin_base_win = Decimal("0")
|
||||
self._bombs: dict[str, Bomb] = {}
|
||||
self._is_free_spin = False
|
||||
|
||||
def buy_free_spins_allowed(self) -> bool:
|
||||
"""当前模式是否允许购买免费旋转(底注与购买互斥)。"""
|
||||
return self.mode.can_buy_free_spins
|
||||
|
||||
def ante_boost_active(self) -> bool:
|
||||
"""当前模式是否启用底注效果(购买免费旋转模式下强制关闭)。"""
|
||||
return self.mode.ante_boost
|
||||
|
||||
def begin_spin(self, is_free_spin: bool = False) -> int:
|
||||
"""is_free_spin=True 表示免费旋转:炸弹进入累计器;基础旋转炸弹仅对本旋生效。"""
|
||||
if self._cascades is not None:
|
||||
raise RuleViolation("当前回合尚未结束")
|
||||
self._cascades = []
|
||||
self._symbol_counts.clear()
|
||||
self._spin_base_win = Decimal("0")
|
||||
self._bombs.clear()
|
||||
self._is_free_spin = is_free_spin
|
||||
return self.state.next_spin_index
|
||||
|
||||
def record_cascade(
|
||||
self,
|
||||
symbols: Mapping[str, Sequence[int]] | None = None,
|
||||
bombs: Iterable[Bomb] = (),
|
||||
) -> CascadeResult:
|
||||
if self._cascades is None:
|
||||
raise RuleViolation("请先调用 begin_spin")
|
||||
|
||||
eliminations = []
|
||||
cascade_base_win = Decimal("0")
|
||||
for symbol, raw_positions in (symbols or {}).items():
|
||||
if symbol not in SYMBOL_RULES:
|
||||
raise RuleViolation(f"未知符号: {symbol}")
|
||||
positions = tuple(raw_positions)
|
||||
count = len(positions)
|
||||
if count < MIN_WIN_COUNT or count > MAX_SYMBOL_COUNT:
|
||||
raise RuleViolation(f"{symbol} 消除数量必须在 {MIN_WIN_COUNT}-{MAX_SYMBOL_COUNT} 之间")
|
||||
if len(set(positions)) != count:
|
||||
raise RuleViolation(f"{symbol} 的消除位置不能重复")
|
||||
|
||||
payout_multiplier = SYMBOL_RULES[symbol].payout_for(count)
|
||||
elimination = Elimination(
|
||||
symbol=symbol,
|
||||
count=count,
|
||||
positions=positions,
|
||||
payout_multiplier=payout_multiplier,
|
||||
)
|
||||
eliminations.append(elimination)
|
||||
cascade_base_win += payout_multiplier * self.bet
|
||||
self._symbol_counts[symbol] += count
|
||||
|
||||
current_bombs = []
|
||||
for bomb in bombs:
|
||||
if bomb.value not in MULTIPLIER_VALUES:
|
||||
raise RuleViolation(f"不支持的炸弹倍数: {bomb.value}")
|
||||
existing = self._bombs.get(bomb.bomb_id)
|
||||
if existing is not None and existing != bomb:
|
||||
raise RuleViolation(f"炸弹 {bomb.bomb_id} 的数据前后不一致")
|
||||
if existing is None:
|
||||
self._bombs[bomb.bomb_id] = bomb
|
||||
current_bombs.append(bomb)
|
||||
|
||||
self._spin_base_win += cascade_base_win
|
||||
result = CascadeResult(
|
||||
cascade_index=len(self._cascades) + 1,
|
||||
eliminations=tuple(eliminations),
|
||||
bombs=tuple(current_bombs),
|
||||
base_win=cascade_base_win,
|
||||
accumulated_symbols=dict(self._symbol_counts),
|
||||
)
|
||||
self._cascades.append(result)
|
||||
return result
|
||||
|
||||
def end_spin(self) -> SpinResult:
|
||||
if self._cascades is None:
|
||||
raise RuleViolation("请先调用 begin_spin")
|
||||
|
||||
multiplier_before = self.state.accumulated_multiplier
|
||||
has_win = self._spin_base_win > 0
|
||||
bombs_added = sum(bomb.value for bomb in self._bombs.values()) if has_win else 0
|
||||
if self._is_free_spin:
|
||||
# 免费旋转:炸弹并入累计器,赢额乘“累计+本旋新炸弹”
|
||||
multiplier_after = multiplier_before + bombs_added
|
||||
effective_multiplier = max(1, multiplier_after) if has_win else 0
|
||||
else:
|
||||
# 基础旋转:无累计器,炸弹仅乘本旋赢额,不留存
|
||||
multiplier_after = multiplier_before
|
||||
effective_multiplier = max(1, bombs_added) if has_win else 0
|
||||
total_win = self._spin_base_win * effective_multiplier
|
||||
|
||||
# 封顶:整局累计赢奖不超过 1000 倍基础下注(最高 1000x 饰品)
|
||||
remaining = self.max_win - self.state.total_win
|
||||
if remaining < 0:
|
||||
remaining = Decimal("0")
|
||||
capped = total_win > remaining
|
||||
if capped:
|
||||
total_win = remaining
|
||||
|
||||
result = SpinResult(
|
||||
spin_index=self.state.next_spin_index,
|
||||
cascades=tuple(self._cascades),
|
||||
base_win=self._spin_base_win,
|
||||
multiplier_before=multiplier_before,
|
||||
bombs_added=bombs_added,
|
||||
multiplier_after=multiplier_after,
|
||||
effective_multiplier=effective_multiplier,
|
||||
total_win=total_win,
|
||||
accumulated_symbols=dict(self._symbol_counts),
|
||||
win_capped=capped,
|
||||
)
|
||||
self.state.spins.append(result)
|
||||
self.state.next_spin_index += 1
|
||||
self.state.accumulated_multiplier = multiplier_after
|
||||
self.state.total_win += total_win
|
||||
self._cascades = None
|
||||
return result
|
||||
|
||||
def reset_session(self) -> None:
|
||||
if self._cascades is not None:
|
||||
raise RuleViolation("当前回合尚未结束")
|
||||
self.state.reset()
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bomb:
|
||||
bomb_id: str
|
||||
value: int
|
||||
position: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Elimination:
|
||||
symbol: str
|
||||
count: int
|
||||
positions: Tuple[int, ...]
|
||||
payout_multiplier: Decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CascadeResult:
|
||||
cascade_index: int
|
||||
eliminations: Tuple[Elimination, ...]
|
||||
bombs: Tuple[Bomb, ...]
|
||||
base_win: Decimal
|
||||
accumulated_symbols: Dict[str, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpinResult:
|
||||
spin_index: int
|
||||
cascades: Tuple[CascadeResult, ...]
|
||||
base_win: Decimal
|
||||
multiplier_before: int
|
||||
bombs_added: int
|
||||
multiplier_after: int
|
||||
effective_multiplier: int
|
||||
total_win: Decimal
|
||||
accumulated_symbols: Dict[str, int]
|
||||
win_capped: bool = False # 本回合赢奖因 1000x 封顶被截断
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionState:
|
||||
next_spin_index: int = 1
|
||||
accumulated_multiplier: int = 0
|
||||
total_win: Decimal = Decimal("0")
|
||||
spins: List[SpinResult] = field(default_factory=list)
|
||||
|
||||
def reset(self) -> None:
|
||||
self.next_spin_index = 1
|
||||
self.accumulated_multiplier = 0
|
||||
self.total_win = Decimal("0")
|
||||
self.spins.clear()
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
Olympus 数据库操作模块
|
||||
|
||||
用法:
|
||||
python olympus_db.py # 建表 + 写入饰品桶种子数据
|
||||
连接参数可用环境变量覆盖: OLY_DB_HOST / OLY_DB_PORT / OLY_DB_USER / OLY_DB_PASSWORD / OLY_DB_NAME
|
||||
默认对齐本地开发库 (127.0.0.1:23306 changzhou,见 docker/compose/docker-compose.yml)。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pymysql
|
||||
|
||||
from olympus.config import ORNAMENTS
|
||||
|
||||
DB_CONFIG = {
|
||||
'host': os.environ.get('OLY_DB_HOST', '127.0.0.1'),
|
||||
'port': int(os.environ.get('OLY_DB_PORT', '23306')),
|
||||
'user': os.environ.get('OLY_DB_USER', 'changzhou'),
|
||||
'password': os.environ.get('OLY_DB_PASSWORD', 'iTSJSPPZM3LSGAPC'),
|
||||
'database': os.environ.get('OLY_DB_NAME', 'changzhou'),
|
||||
'charset': 'utf8mb4',
|
||||
'local_infile': True,
|
||||
}
|
||||
|
||||
SCHEMA_FILE = Path(__file__).resolve().parent / 'schema.sql'
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_conn():
|
||||
conn = pymysql.connect(**DB_CONFIG)
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 建表(执行 schema.sql 中所有语句,注释行自动跳过)
|
||||
# ============================================================
|
||||
|
||||
def _load_schema_statements():
|
||||
text = SCHEMA_FILE.read_text(encoding='utf-8')
|
||||
lines = [ln for ln in text.splitlines() if not ln.strip().startswith('--')]
|
||||
statements = [s.strip() for s in '\n'.join(lines).split(';') if s.strip()]
|
||||
return statements
|
||||
|
||||
|
||||
def create_tables():
|
||||
"""建表 + 写入饰品桶种子数据(幂等,可重复执行)"""
|
||||
stmts = _load_schema_statements()
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
for sql in stmts:
|
||||
cur.execute(sql)
|
||||
print(f'OK: executed {len(stmts)} statements (tables + ornament seed).')
|
||||
|
||||
|
||||
def verify_tables():
|
||||
"""校验所有 olympus 表已存在并输出行数"""
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SHOW TABLES LIKE 'game_olympus_%'")
|
||||
tables = [row[0] for row in cur.fetchall()]
|
||||
for t in sorted(tables):
|
||||
cur.execute(f"SELECT COUNT(*) FROM {t}")
|
||||
print(f'{t}: {cur.fetchone()[0]} rows')
|
||||
return tables
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Ornament 操作
|
||||
# ============================================================
|
||||
|
||||
def insert_ornaments():
|
||||
"""按 config.ORNAMENTS 覆盖写入饰品桶(与 schema.sql 种子一致)"""
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
for o in ORNAMENTS:
|
||||
cur.execute("""
|
||||
INSERT INTO game_olympus_ornament (id, game_type, expect_mul, name, range_low, range_high)
|
||||
VALUES (%s,'OLYMPUS',%s,%s,%s,%s)
|
||||
ON DUPLICATE KEY UPDATE expect_mul=VALUES(expect_mul), name=VALUES(name),
|
||||
range_low=VALUES(range_low), range_high=VALUES(range_high)
|
||||
""", (o['id'], o['expect_mul'], o['name'], o['range_low'], o['range_high']))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Box 操作
|
||||
# ============================================================
|
||||
|
||||
def insert_box(box):
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO game_olympus_box (box_id, game_type, box_type, bet, name, price,
|
||||
total_ornaments, rtp, sort, status)
|
||||
VALUES (%s,'OLYMPUS',%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
ON DUPLICATE KEY UPDATE box_type=VALUES(box_type),
|
||||
bet=VALUES(bet), name=VALUES(name),
|
||||
price=VALUES(price), total_ornaments=VALUES(total_ornaments),
|
||||
rtp=VALUES(rtp)
|
||||
""", (box['box_id'], box['box_type'], box['bet'], box['name'],
|
||||
box['price'], box['total_ornaments'], box['rtp'],
|
||||
box.get('sort', 0), box.get('status', '1')))
|
||||
|
||||
|
||||
def query_boxes():
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||
cur.execute("SELECT * FROM game_olympus_box WHERE status='1' ORDER BY box_id")
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Round / Step 批量写入(单连接清空+写入,死锁自动重试)
|
||||
# ============================================================
|
||||
|
||||
def get_max_round_id():
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COALESCE(MAX(round_id), 0) FROM game_olympus_round")
|
||||
return cur.fetchone()[0]
|
||||
|
||||
|
||||
def get_max_step_id():
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COALESCE(MAX(id), 0) FROM game_olympus_step")
|
||||
return cur.fetchone()[0]
|
||||
|
||||
|
||||
def _j(v, default='[]'):
|
||||
if v is None:
|
||||
return default
|
||||
return v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)
|
||||
|
||||
|
||||
def write_box_distribution(box_id, counts, audience='common'):
|
||||
"""单连接:按身份清空并写入饰品桶分布(只动该 audience,不影响其他身份)"""
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM game_olympus_box_ornament WHERE box_id=%s AND audience=%s",
|
||||
(box_id, audience))
|
||||
if counts:
|
||||
sql = ("INSERT INTO game_olympus_box_ornament"
|
||||
" (box_id, ornament_id, audience, count, used)"
|
||||
" VALUES (%s,%s,%s,%s,0)")
|
||||
rows = [(box_id, oid, audience, cnt)
|
||||
for oid, cnt in sorted(counts.items()) if cnt > 0]
|
||||
cur.executemany(sql, rows)
|
||||
return
|
||||
except Exception as e:
|
||||
if 'Deadlock' in str(e) and attempt < 4:
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
def write_box_distributions(box_id, counts_by_audience):
|
||||
"""一次写入多套身份分布。counts_by_audience: {audience: {ornament_id: count}}"""
|
||||
for audience, counts in counts_by_audience.items():
|
||||
write_box_distribution(box_id, counts, audience)
|
||||
|
||||
|
||||
def write_rounds_and_steps(box_id, rounds_data, steps_data):
|
||||
"""单连接:清空旧回合+步骤,批量写入新数据"""
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""DELETE FROM game_olympus_step WHERE round_id IN
|
||||
(SELECT round_id FROM game_olympus_round WHERE box_id=%s)""", (box_id,))
|
||||
cur.execute("DELETE FROM game_olympus_round WHERE box_id=%s", (box_id,))
|
||||
if rounds_data:
|
||||
sql_r = """INSERT INTO game_olympus_round
|
||||
(round_id, box_id, ornament_id, actual_mul, free_spin_count, total_cascades)
|
||||
VALUES (%s,%s,%s,%s,%s,%s)"""
|
||||
rows_r = [(rd['round_id'], rd['box_id'], rd['ornament_id'],
|
||||
rd['actual_mul'], rd['free_spin_count'], rd['total_cascades'])
|
||||
for rd in rounds_data]
|
||||
cur.executemany(sql_r, rows_r)
|
||||
if steps_data:
|
||||
sql_s = """INSERT INTO game_olympus_step
|
||||
(id, round_id, free_spin_id, cascade_seq, grid_state,
|
||||
multiplier_detail, symbol_links, accumulated_symbols, spin_winners,
|
||||
removed_positions, drop_data, fill_data,
|
||||
scatter_trigger, retrigger_info, base_score, win_amount,
|
||||
accumulator_before, accumulator_after, spin_win, win_capped,
|
||||
bigwin_level, is_final)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)"""
|
||||
rows_s = [(s['id'], s['round_id'], s['free_spin_id'], s['cascade_seq'],
|
||||
_j(s['grid_state']),
|
||||
_j(s.get('multiplier_detail')),
|
||||
_j(s.get('symbol_links')),
|
||||
_j(s.get('accumulated_symbols'), default='{}'),
|
||||
_j(s.get('spin_winners')),
|
||||
_j(s.get('removed_positions')),
|
||||
_j(s.get('drop_data')),
|
||||
_j(s.get('fill_data')),
|
||||
_j(s.get('scatter_trigger'), default=None),
|
||||
_j(s.get('retrigger_info'), default=None),
|
||||
s['base_score'], s['win_amount'],
|
||||
s['accumulator_before'], s['accumulator_after'],
|
||||
s['spin_win'], s.get('win_capped', 0),
|
||||
s.get('bigwin_level'), s['is_final'])
|
||||
for s in steps_data]
|
||||
cur.executemany(sql_s, rows_s)
|
||||
return
|
||||
except Exception as e:
|
||||
if 'Deadlock' in str(e) and attempt < 4:
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
create_tables()
|
||||
verify_tables()
|
||||
@@ -0,0 +1,805 @@
|
||||
"""
|
||||
Olympus 回合生成器核心 — 正向生成
|
||||
|
||||
流程: 生成 cascade → 自然累计 → 总分落桶
|
||||
核心规则(与 olympus/engine.py 对齐):
|
||||
1. 一旋转内多次消除,中间步只记基础分,末帧统一引爆炸弹
|
||||
2. 炸弹倍数跨旋转累计(accumulator 贯穿整个回合:基础旋转 + 免费旋转)
|
||||
3. 每步 accumulated_symbols 记录本旋转累计消除符号
|
||||
4. 落桶用理论总分(最高 1370),发放封顶 1000(末步截断 + win_capped)
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
from collections import Counter
|
||||
|
||||
from olympus.config import (
|
||||
SYMBOL_RULES, SHORT_TO_SYMBOL, SCATTER_SHORT, BOMB_SHORT,
|
||||
SYMBOL_WEIGHTS, BOMB_CELL_PROBABILITY, BOMB_VALUES, BOMB_WEIGHT_POWER,
|
||||
GRID_ROWS, GRID_COLS, GRID_SIZE, MIN_WIN_COUNT,
|
||||
SCATTER_PAYOUTS, SCATTER_FREE_SPIN_TRIGGER, SCATTER_RETRIGGER_TRIGGER,
|
||||
FREE_SPINS_AWARDED, FREE_SPIN_RETRIGGER_COUNT, MAX_FREE_SPINS,
|
||||
BASE_FS_TRIGGER_PROB, BASE_NEARMISS_SCATTER, FS_TRIGGER_SCATTER, FREESPIN_SCATTER,
|
||||
FS_SPIN_WIN_PROB, FS_CASCADE_CONTINUE_PROB, FS_CASCADE_MAX, FS_GUARANTEE_COMBOS,
|
||||
FS_GUARANTEE_COST, FS_DRY_FORCE_PROB, FS_DRY_FORCE_HARD,
|
||||
FS_SMALL_BOMB_MAX, FS_BIG_BOMB_TARGET, FS_SCREEN_BOMB_PROB, FS_MAX_BOMBS_PER_SPIN,
|
||||
FS_ONLY_TARGET_MUL,
|
||||
ORNAMENTS, MAX_WIN_MULTIPLIER, bomb_quality, bigwin_level,
|
||||
)
|
||||
|
||||
REGULAR_SHORTS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']
|
||||
MAX_WIN = float(MAX_WIN_MULTIPLIER) # 发放封顶 1000
|
||||
BUCKET_CEIL = max(o['range_high'] for o in ORNAMENTS) # 落桶理论上限 1370
|
||||
|
||||
# 保底消除预算(1 倍底注口径):最小档 0.25,连消后期望 1/(1-p) 波
|
||||
FS_FLOOR_UNIT = min(c[2] for c in FS_GUARANTEE_COMBOS)
|
||||
FS_FLOOR_SPEND = FS_FLOOR_UNIT / (1.0 - FS_CASCADE_CONTINUE_PROB) # ≈0.45/旋
|
||||
FS_FLOOR_BUDGET_CAP = FREE_SPINS_AWARDED * FS_FLOOR_SPEND # ≈6.8/局
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# 工具
|
||||
# ====================================================================
|
||||
|
||||
def _payout(short, cnt):
|
||||
return float(SYMBOL_RULES[SHORT_TO_SYMBOL[short]].payout_for(cnt))
|
||||
|
||||
|
||||
def _weighted_sym(exclude=()):
|
||||
syms = [s for s in REGULAR_SHORTS if s not in exclude]
|
||||
wts = [SYMBOL_WEIGHTS[s] for s in syms]
|
||||
r = random.uniform(0, sum(wts))
|
||||
a = 0.0
|
||||
for s, w in zip(syms, wts):
|
||||
a += w
|
||||
if r <= a:
|
||||
return s
|
||||
return syms[-1]
|
||||
|
||||
|
||||
def _weighted_choice(dist):
|
||||
r = random.random()
|
||||
cum, last = 0.0, None
|
||||
for k, p in dist.items():
|
||||
last = k
|
||||
cum += p
|
||||
if r <= cum:
|
||||
return k
|
||||
return last
|
||||
|
||||
|
||||
def _bucket(score):
|
||||
"""理论总分落桶(score 可到 1370+,超出归顶桶)"""
|
||||
if score <= 0:
|
||||
return 1
|
||||
if score > BUCKET_CEIL:
|
||||
return ORNAMENTS[-1]['id']
|
||||
for o in ORNAMENTS:
|
||||
if o['range_low'] <= score <= o['range_high']:
|
||||
return o['id']
|
||||
for o in ORNAMENTS[1:]:
|
||||
if score <= o['range_high']:
|
||||
return o['id']
|
||||
return ORNAMENTS[-1]['id']
|
||||
|
||||
|
||||
def _gen_bomb_value(max_val=None):
|
||||
opts = [v for v in BOMB_VALUES if max_val is None or v <= max_val]
|
||||
wts = [1.0 / (v ** BOMB_WEIGHT_POWER) for v in opts]
|
||||
r = random.uniform(0, sum(wts))
|
||||
a = 0.0
|
||||
for v, w in zip(opts, wts):
|
||||
a += w
|
||||
if r <= a:
|
||||
return v
|
||||
return opts[-1]
|
||||
|
||||
|
||||
def _bomb_combos(target):
|
||||
"""列出 ≤3 颗炸弹、和最接近 target 的所有组合:(误差, 组合),按误差升序"""
|
||||
out = []
|
||||
vals = BOMB_VALUES
|
||||
for a in vals:
|
||||
out.append((abs(a - target), [a]))
|
||||
if a > target * 1.5:
|
||||
break
|
||||
for a in vals:
|
||||
if a > target:
|
||||
break
|
||||
for b in vals:
|
||||
s = a + b
|
||||
if s > target * 1.5:
|
||||
break
|
||||
out.append((abs(s - target), [a, b]))
|
||||
for a in vals:
|
||||
if a > target:
|
||||
break
|
||||
for b in vals:
|
||||
if a + b > target:
|
||||
break
|
||||
for c in vals:
|
||||
s = a + b + c
|
||||
if s > target * 1.5:
|
||||
break
|
||||
out.append((abs(s - target), [a, b, c]))
|
||||
out.sort(key=lambda x: (x[0], len(x[1])))
|
||||
return out
|
||||
|
||||
|
||||
def _best_bombs(target):
|
||||
"""确定性最优(只用于评估方案误差)"""
|
||||
combos = _bomb_combos(target)
|
||||
return combos[0][1] if combos else [2]
|
||||
|
||||
|
||||
def _decompose_best(target):
|
||||
"""在"接近最优"的组合里随机挑一个,并强烈偏向单颗 —— 一屏一颗,
|
||||
玩家才看得出"消一次爆一颗、倍数一颗一颗涨";改前每次都返回同一套
|
||||
(比如某些目标永远是 [3] 或 [3,6]),看起来"消除必定带 3 和 6 倍炸弹"。"""
|
||||
combos = _bomb_combos(target)
|
||||
if not combos:
|
||||
return [2]
|
||||
best_err = combos[0][0]
|
||||
pool = [c for e, c in combos if e <= best_err + max(0.5, best_err * 0.15)]
|
||||
weights = [1.0 / (len(c) ** 3) for c in pool] # 1 颗 : 2 颗 : 3 颗 = 1 : 1/8 : 1/27
|
||||
return random.choices(pool, weights=weights)[0]
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# 网格
|
||||
# ====================================================================
|
||||
|
||||
def _empty_grid():
|
||||
return [[''] * GRID_COLS for _ in range(GRID_ROWS)]
|
||||
|
||||
|
||||
def _count(grid):
|
||||
c = Counter()
|
||||
for row in grid:
|
||||
for cell in row:
|
||||
if cell:
|
||||
c[cell] += 1
|
||||
return c
|
||||
|
||||
|
||||
def _winners(grid):
|
||||
w = {}
|
||||
for sym, cnt in _count(grid).items():
|
||||
if sym in (BOMB_SHORT, SCATTER_SHORT, ''):
|
||||
continue
|
||||
if cnt >= MIN_WIN_COUNT:
|
||||
pay = _payout(sym, cnt)
|
||||
if pay > 0:
|
||||
w[sym] = (cnt, pay)
|
||||
return w
|
||||
|
||||
|
||||
def _no_extra(grid, allowed):
|
||||
"""打散计划外的 ≥8 同符号组"""
|
||||
backup = [s for s in REGULAR_SHORTS if s not in allowed] or ['I']
|
||||
for _ in range(50):
|
||||
dirty = False
|
||||
for sym, cnt in _count(grid).items():
|
||||
if sym in (BOMB_SHORT, SCATTER_SHORT, '') or sym in allowed:
|
||||
continue
|
||||
if cnt >= MIN_WIN_COUNT:
|
||||
excess = cnt - (MIN_WIN_COUNT - 1)
|
||||
for r in range(GRID_ROWS):
|
||||
for c in range(GRID_COLS):
|
||||
if grid[r][c] == sym and excess > 0:
|
||||
grid[r][c] = random.choice(backup)
|
||||
excess -= 1
|
||||
dirty = True
|
||||
if not dirty:
|
||||
break
|
||||
|
||||
|
||||
def _fill(grid, mul_map, allow_bomb, track=False, exclude_syms=(),
|
||||
bomb_prob=BOMB_CELL_PROBABILITY, bomb_max=None, max_bombs=None):
|
||||
"""填充空格。allow_bomb 时按 bomb_prob 概率放炸弹(bomb_max 限制炸弹值、max_bombs 限制每屏颗数)。
|
||||
track 返回填充数据(仅普通符号)"""
|
||||
filled = [] if track else None
|
||||
placed = len(mul_map)
|
||||
for r in range(GRID_ROWS):
|
||||
for c in range(GRID_COLS):
|
||||
if grid[r][c] == '':
|
||||
pos = r * GRID_COLS + c
|
||||
if allow_bomb and random.random() < bomb_prob \
|
||||
and (max_bombs is None or placed < max_bombs):
|
||||
grid[r][c] = BOMB_SHORT
|
||||
mul_map[pos] = _gen_bomb_value(bomb_max)
|
||||
placed += 1
|
||||
else:
|
||||
sym = _weighted_sym(exclude_syms)
|
||||
grid[r][c] = sym
|
||||
if track:
|
||||
filled.append({"pos": pos + 1, "symbol": sym})
|
||||
return filled
|
||||
|
||||
|
||||
def _collapse(grid, remove, mul_map):
|
||||
"""消除指定位置(0-based),重力下落。炸弹/散布随位置移动,mul_map 同步重映射"""
|
||||
rs = set(remove)
|
||||
drops = []
|
||||
ng = _empty_grid()
|
||||
pos_remap = {}
|
||||
for c in range(GRID_COLS):
|
||||
surviving = []
|
||||
for r in range(GRID_ROWS - 1, -1, -1):
|
||||
p = r * GRID_COLS + c
|
||||
if p not in rs:
|
||||
surviving.append((p, grid[r][c]))
|
||||
for i, (old_pos, sym) in enumerate(surviving):
|
||||
new_r = GRID_ROWS - 1 - i
|
||||
new_pos = new_r * GRID_COLS + c
|
||||
ng[new_r][c] = sym
|
||||
pos_remap[old_pos] = new_pos
|
||||
if new_pos != old_pos:
|
||||
drops.append({"from": old_pos + 1, "to": new_pos + 1, "symbol": sym})
|
||||
new_mul = {pos_remap[p]: v for p, v in mul_map.items() if p in pos_remap}
|
||||
return ng, drops, new_mul
|
||||
|
||||
|
||||
def _place_scatter(grid, count, protect=()):
|
||||
cand = [(r, c) for r in range(GRID_ROWS) for c in range(GRID_COLS)
|
||||
if grid[r][c] not in ({SCATTER_SHORT, BOMB_SHORT} | set(protect))]
|
||||
for r, c in random.sample(cand, min(count, len(cand))):
|
||||
grid[r][c] = SCATTER_SHORT
|
||||
|
||||
|
||||
def _count_scatter(grid):
|
||||
return sum(1 for row in grid for cell in row if cell == SCATTER_SHORT)
|
||||
|
||||
|
||||
def _force_small_win(grid, prefer=()):
|
||||
"""就地补一组最小档消除(默认 8 连低价值符号),不覆盖已有 S/M 格。
|
||||
|
||||
prefer = 这一波"刚落下 / 刚补位"的格子(0 基)。保底消除优先种在这些格子上:
|
||||
它们本来就有 drop/fill 记录,前端按动画数据落地就能与 grid_state 完全一致;
|
||||
种在**没移动过**的格子上则没有任何动画记录,前端只能靠 grid_state 兜底。"""
|
||||
sym, cnt, _ = random.choice(FS_GUARANTEE_COMBOS)
|
||||
free = [p for p in range(GRID_SIZE)
|
||||
if grid[p // GRID_COLS][p % GRID_COLS] not in (SCATTER_SHORT, BOMB_SHORT)]
|
||||
if len(free) < cnt:
|
||||
return
|
||||
fresh = [p for p in prefer if p in set(free)]
|
||||
if len(fresh) >= cnt:
|
||||
picks = random.sample(fresh, cnt)
|
||||
else:
|
||||
rest = [p for p in free if p not in set(fresh)]
|
||||
picks = fresh + random.sample(rest, cnt - len(fresh))
|
||||
for p in picks:
|
||||
grid[p // GRID_COLS][p % GRID_COLS] = sym
|
||||
_no_extra(grid, {sym})
|
||||
|
||||
|
||||
def _scatter_payout(count):
|
||||
if count >= SCATTER_FREE_SPIN_TRIGGER:
|
||||
return float(SCATTER_PAYOUTS.get(min(count, 6), 0))
|
||||
return 0.0
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# 强制中奖(bias 补桶用)
|
||||
# ====================================================================
|
||||
|
||||
# (基础分, 组合) 按基础分降序
|
||||
_BASE_OPTIONS = [
|
||||
(60.0, [('A', 12), ('B', 10)]),
|
||||
(50.0, [('A', 12)]),
|
||||
(35.0, [('A', 10), ('B', 10)]),
|
||||
(25.0, [('A', 10)]),
|
||||
(12.5, [('A', 8), ('B', 8)]),
|
||||
(10.0, [('A', 8)]),
|
||||
(5.0, [('C', 10)]),
|
||||
(3.5, [('C', 8), ('D', 8)]),
|
||||
(2.5, [('B', 8)]),
|
||||
(2.0, [('C', 8)]),
|
||||
(1.5, [('D', 8)]),
|
||||
(1.0, [('E', 8)]),
|
||||
(0.8, [('F', 8)]),
|
||||
(0.5, [('G', 8)]),
|
||||
(0.4, [('H', 8)]),
|
||||
(0.25, [('I', 8)]),
|
||||
]
|
||||
|
||||
|
||||
def _plan_for(target):
|
||||
"""全局搜索 (基础组合, 炸弹列表),使 base × max(1, Σ炸弹) 最接近 target。
|
||||
|
||||
在"误差在最优 +5 个百分点以内"的若干方案里随机挑一个 —— 改前永远返回同一套最优解,
|
||||
所以同一档位的回合盘面/炸弹老是那几个("某个符号消除必定带着 3 和 6 倍炸弹")。"""
|
||||
if target <= 0:
|
||||
return [], []
|
||||
plans = []
|
||||
for base, combo in _BASE_OPTIONS:
|
||||
plans.append((abs(base - target) / target, combo, None)) # 方案A:无炸弹
|
||||
m = target / base
|
||||
if m >= 2: # 方案B:配炸弹
|
||||
achieved = base * sum(_best_bombs(m))
|
||||
plans.append((abs(achieved - target) / target, combo, m))
|
||||
plans.sort(key=lambda p: p[0])
|
||||
best_err = plans[0][0]
|
||||
pool = [p for p in plans if p[0] <= best_err + 0.05]
|
||||
_, combo, m = random.choice(pool)
|
||||
return combo, ([] if m is None else _decompose_best(m))
|
||||
|
||||
|
||||
def _plan_base_only(target):
|
||||
"""只挑基础组合(不植炸弹):在接近最优的组合里随机。acc 已经提供倍数时用这个。"""
|
||||
if target <= 0:
|
||||
return [('I', 8)]
|
||||
cands = sorted((abs(base - target) / target, combo) for base, combo in _BASE_OPTIONS)
|
||||
best = cands[0][0]
|
||||
pool = [c for e, c in cands if e <= best + 0.05]
|
||||
return random.choice(pool)
|
||||
|
||||
|
||||
def _force_win(grid, target, mul_map, acc=0.0, allow_bomb=True):
|
||||
"""把 target 当"本旋最终赢额"来造奖:先按当前累计倍数 acc 折算需要的基础分。
|
||||
|
||||
买单旋赢额 = 基础分 × max(1, acc + 本旋炸弹),所以:
|
||||
· acc 已经 >1 时只挑基础分、**不再植炸弹**(否则 acc 和炸弹的倍数会被算两次,
|
||||
实测把 10 倍目标打成 40~50 倍);
|
||||
· 该模式不允许炸弹(普通旋转/底注)时同理只挑基础分;
|
||||
· 其余情况(buy_fs 且 acc=0)按原逻辑植炸弹,大奖局(100 倍以上)靠这一步才能到。"""
|
||||
need_base = target / max(1.0, acc) if acc > 0 else target
|
||||
if need_base >= 0.25:
|
||||
if acc > 1 or not allow_bomb:
|
||||
combo, bombs = _plan_base_only(need_base), []
|
||||
else:
|
||||
combo, bombs = _plan_for(need_base)
|
||||
else:
|
||||
combo, bombs = [('I', 8)], [] # acc 本身已超过目标:只给最小档,不再加炸弹
|
||||
used = set()
|
||||
win_syms = set()
|
||||
for sym, cnt in combo:
|
||||
win_syms.add(sym)
|
||||
free = [p for p in range(GRID_SIZE) if p not in used]
|
||||
for p in random.sample(free, min(cnt, len(free))):
|
||||
r, c = divmod(p, GRID_COLS)
|
||||
grid[r][c] = sym
|
||||
used.add(p)
|
||||
free = [p for p in range(GRID_SIZE) if p not in used]
|
||||
for v in bombs:
|
||||
if not free:
|
||||
break
|
||||
p = free.pop(random.randrange(len(free)))
|
||||
r, c = divmod(p, GRID_COLS)
|
||||
grid[r][c] = BOMB_SHORT
|
||||
mul_map[p] = v
|
||||
used.add(p)
|
||||
for p in range(GRID_SIZE):
|
||||
r, c = divmod(p, GRID_COLS)
|
||||
if grid[r][c] == '':
|
||||
grid[r][c] = _weighted_sym(exclude=win_syms)
|
||||
_no_extra(grid, win_syms)
|
||||
return win_syms
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# 核心:一次旋转的 cascade 链
|
||||
# ====================================================================
|
||||
|
||||
def _spin(mode_code, bias=None, acc=0, is_free_spin=False, target_scatter=0,
|
||||
allow_bomb=True, win_prob=0.0, continue_prob=0.0, cascade_max=0,
|
||||
bomb_prob=None, bomb_max=None, max_bombs=None):
|
||||
"""
|
||||
一次旋转(含多次 cascade)。
|
||||
炸弹跨 cascade 保留不消除;末帧统一结算: total = base_sum * max(1, acc+bomb_sum)
|
||||
acc 只在本旋转有消除时累加炸弹(与 engine.end_spin 一致)。
|
||||
allow_bomb=False 时初始盘也不出炸弹;级联补位一律不出。
|
||||
**炸弹只存在于免费旋转内**:普通旋转/底注的基础旋(含进 FS 的那张触发盘)一律不出炸弹;
|
||||
三个模式进 FS 后规则完全一致(炸弹 + 累计器 + 每旋保底消除)。
|
||||
win_prob/continue_prob:本盘凑不出消除时按概率补最小档消除(首盘/级联各自取一个),
|
||||
cascade_max 限制单旋最大波数 —— 让 FS 里"每旋都在消"。
|
||||
返回 (steps, spin_total, acc_out, scatter_count)
|
||||
"""
|
||||
if not is_free_spin:
|
||||
allow_bomb = False
|
||||
steps = []
|
||||
seq = 0
|
||||
mul_map = {} # {pos0: bomb_val} 跨 cascade 累积
|
||||
if bomb_prob is None:
|
||||
bomb_prob = BOMB_CELL_PROBABILITY
|
||||
# 本旋是不是"大奖旋"(补桶植入目标额的那一旋):只有它按累计倍数结算
|
||||
plant_spin = isinstance(bias, (int, float)) and bias > 0
|
||||
|
||||
grid = None
|
||||
prev_win_pos = []
|
||||
sym_acc = Counter() # 本旋转累计消除符号
|
||||
spin_winners = [] # 落库留档用(接口已不下发,面板由 symbol_links 汇总)
|
||||
spin_links = [] # 本旋累积连线上报:[{symbol,count,loc,base_score,tumble}],新旋清空
|
||||
total_base = 0.0
|
||||
recorded = 0.0 # 已记入前置步骤的赢额
|
||||
removed, drops, fills = [], [], []
|
||||
acc_before_spin = acc
|
||||
|
||||
while True:
|
||||
seq += 1
|
||||
if seq == 1:
|
||||
grid = _empty_grid()
|
||||
if isinstance(bias, (int, float)) and bias > 0:
|
||||
win_syms = _force_win(grid, float(bias), mul_map, acc, allow_bomb)
|
||||
else:
|
||||
_fill(grid, mul_map, allow_bomb, bomb_prob=bomb_prob, bomb_max=bomb_max,
|
||||
max_bombs=max_bombs)
|
||||
if bias == 'zero':
|
||||
_no_extra(grid, set())
|
||||
if target_scatter > 0:
|
||||
_place_scatter(grid, target_scatter, protect=set(_winners(grid)))
|
||||
removed, drops, fills = [], [], []
|
||||
else:
|
||||
removed = [p + 1 for p in prev_win_pos]
|
||||
grid, drops, mul_map = _collapse(grid, prev_win_pos, mul_map)
|
||||
fills = _fill(grid, mul_map, allow_bomb=False, track=True)
|
||||
|
||||
# 保底消除:本盘自然凑不出 8 连时,按概率补一组最小档(首盘用 win_prob,级联用 continue_prob)
|
||||
prob = win_prob if seq == 1 else continue_prob
|
||||
if prob > 0 and (not cascade_max or seq < cascade_max) \
|
||||
and random.random() < prob and not _winners(grid):
|
||||
_force_small_win(grid, prefer=[f['pos'] - 1 for f in fills])
|
||||
|
||||
# 保底消除 / 打散会覆盖"刚落下"或"刚补位"的格子 —— 这两份动画数据里的 symbol 按**最终盘面**
|
||||
# 回填,前端按"消→落→补"重建出来的盘面才与 grid_state 完全一致(否则会画出不对的符号)。
|
||||
if drops:
|
||||
drops = [dict(d, symbol=grid[(d['to'] - 1) // GRID_COLS][(d['to'] - 1) % GRID_COLS])
|
||||
for d in drops]
|
||||
if fills:
|
||||
fills = [dict(f, symbol=grid[(f['pos'] - 1) // GRID_COLS][(f['pos'] - 1) % GRID_COLS])
|
||||
for f in fills]
|
||||
|
||||
w = _winners(grid)
|
||||
is_final = (len(w) == 0)
|
||||
|
||||
base = 0.0
|
||||
win_pos = []
|
||||
mul_det = [{"pos": p + 1, "val": v, "quality": bomb_quality(v)}
|
||||
for p, v in sorted(mul_map.items())]
|
||||
bomb_sum = sum(mul_map.values())
|
||||
|
||||
# symbol_links = **本旋累积**的连线上报(每波消除往上追加,tumble 标明是旋内第几波)——
|
||||
# 它同时充当"获胜者面板"(前端按 symbol 汇总即可),所以 spin_winners 字段已删。
|
||||
for sym, (cnt, pay) in sorted(w.items()):
|
||||
pos_list = [r * GRID_COLS + c for r in range(GRID_ROWS)
|
||||
for c in range(GRID_COLS) if grid[r][c] == sym][:cnt]
|
||||
base += pay
|
||||
win_pos.extend(pos_list)
|
||||
sym_acc[sym] += cnt
|
||||
spin_links.append({
|
||||
'symbol': sym, 'count': cnt,
|
||||
'loc': [p + 1 for p in pos_list],
|
||||
'base_score': round(pay, 4),
|
||||
'tumble': seq, # 本旋第几波消除(= 本步 tumbleNum)
|
||||
})
|
||||
total_base += base
|
||||
|
||||
# 末帧:统一引爆(一旋转内不管几次消除,消完才爆)
|
||||
# 累计器仅免费旋转有效;基础旋转炸弹只乘本旋赢额、不留存
|
||||
scatter_win = 0.0
|
||||
scatter_count = 0
|
||||
acc_after = acc
|
||||
if is_final:
|
||||
scatter_count = _count_scatter(grid)
|
||||
scatter_win = _scatter_payout(scatter_count)
|
||||
if total_base > 0:
|
||||
if is_free_spin:
|
||||
acc_after = acc + bomb_sum # FS:炸弹并入累计器,跨旋传递
|
||||
# 本旋赢额 = 基础分 × max(1, 累计 + 本旋炸弹) —— 累计倍数作用到每一次消除
|
||||
# (玩家要的就是这个滚雪球:0.4×2 + 0.5×4 = 2.8)
|
||||
effective = max(1, acc_after)
|
||||
else:
|
||||
acc_after = acc # 基础旋:无累计器
|
||||
effective = max(1, bomb_sum)
|
||||
win_amt = total_base * effective - recorded + scatter_win
|
||||
else:
|
||||
win_amt = scatter_win # 无消除→炸弹作废、累计不变
|
||||
else:
|
||||
win_amt = base # 中间步只记基础分
|
||||
recorded += win_amt if not is_final else 0.0
|
||||
|
||||
# scatter 触发/重触发(仅末帧)
|
||||
scatter_trigger, retrigger_info = None, None
|
||||
if is_final:
|
||||
if is_free_spin and scatter_count >= SCATTER_RETRIGGER_TRIGGER:
|
||||
retrigger_info = {'scatterCount': scatter_count,
|
||||
'extraSpins': FREE_SPIN_RETRIGGER_COUNT,
|
||||
'scatterWin': round(scatter_win, 2)}
|
||||
elif not is_free_spin and scatter_count >= SCATTER_FREE_SPIN_TRIGGER:
|
||||
scatter_trigger = {'scatterCount': scatter_count,
|
||||
'freeSpins': FREE_SPINS_AWARDED,
|
||||
'scatterWin': round(scatter_win, 2)}
|
||||
|
||||
steps.append({
|
||||
'cascade_seq': seq, 'free_spin_id': 0,
|
||||
'grid_state': [row[:] for row in grid],
|
||||
'multiplier_detail': json.dumps(mul_det),
|
||||
'symbol_links': json.dumps(spin_links, ensure_ascii=False), # 本旋累积(含 tumble)
|
||||
'accumulated_symbols': json.dumps(dict(sym_acc)), # 仅落库留档,不下发
|
||||
'spin_winners': json.dumps(spin_winners, ensure_ascii=False), # 仅落库留档,不下发
|
||||
'removed_positions': json.dumps(removed), # 仅落库留档,不下发
|
||||
'drop_data': json.dumps(drops),
|
||||
'fill_data': json.dumps(fills if fills else []),
|
||||
'scatter_trigger': json.dumps(scatter_trigger) if scatter_trigger else None,
|
||||
'retrigger_info': json.dumps(retrigger_info) if retrigger_info else None,
|
||||
'base_score': round(base, 2), # 仅落库留档,不下发
|
||||
'win_amount': round(win_amt, 2),
|
||||
'accumulator_before': round(acc_before_spin, 2),
|
||||
'accumulator_after': round(acc_after if is_final else acc, 2),
|
||||
'spin_win': 0, 'win_capped': 0, 'bigwin_level': None,
|
||||
'is_final': 1 if is_final else 0,
|
||||
})
|
||||
|
||||
if is_final:
|
||||
total = round(sum(s['win_amount'] for s in steps), 2)
|
||||
return steps, total, acc_after, scatter_count
|
||||
prev_win_pos = win_pos
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# 免费旋转会话(15 次起,≥3S 重触发 +5,上限 30)
|
||||
# ====================================================================
|
||||
|
||||
|
||||
def _fs_next_target(spin_no, wins, total_spins, quota):
|
||||
"""下一旋"该保底消除"的位置:间隔随机,但均值 = 剩余旋数 / 剩余配额。
|
||||
|
||||
为什么要随机:等距铺(每 quota/总局数 旋必消一次)会让玩家几局就看穿"到第 4 旋肯定消"——
|
||||
实测等距版本有 87% 的局"最长连空正好 4 旋"。这里改成每次消除后重抽下一次的位置:
|
||||
间隔 = round(均值 + δ),δ ∈ {-2..2} 权重 {1,2,3,2,1}(均值 0,所以整局的消除次数不变),
|
||||
玩家看到的空转长度在 1~5 旋之间跳,而不是恒定 4。剩余配额为 0 时返回 None(后面只靠自然消)。
|
||||
"""
|
||||
left_spins = total_spins - spin_no
|
||||
left_quota = quota - wins
|
||||
if left_spins <= 0 or left_quota <= 0:
|
||||
return None
|
||||
delta = random.choices((-2, -1, 0, 1, 2), weights=(1, 2, 3, 2, 1))[0]
|
||||
return spin_no + max(1, int(round(left_spins / left_quota + delta)))
|
||||
|
||||
|
||||
def _free_spin_session(mode_code, slot_target=None, acc=0, fs_budget=None):
|
||||
"""免费旋转会话(15 次起,≥3S 重触发 +5,上限 30)。
|
||||
|
||||
**三个模式同一套规则**(炸弹 + 累计器 + 每旋保底消除),区别只在"这一局值多少钱":
|
||||
· buy_fs:价格换来桶目标,拆成「保底消除预算 fs_budget + 一旋大奖 slot_target」;
|
||||
· normal/ante 自然触发:没有价格可拆 → 只给一份同档保底预算、不植大奖旋,
|
||||
整局赔付落在哪由自然结果决定。
|
||||
fs_budget 按"配额 + 随机间隔"花:预算能买 k 次消除(k ≈ √(预算/FS_GUARANTEE_COST)),
|
||||
每次消除后重抽下一次的位置(间隔随机、均值 = 剩余旋数/剩余配额),自然消除也算一次进度;
|
||||
空转太久再按 FS_DRY_FORCE_PROB 软兜底 —— 旧写法按"剩余预算÷剩余旋数"算每旋概率,累计倍数
|
||||
一涨就把预算烧穿,后半局十几旋连空;等距铺法又会让玩家看穿"到第 4 旋肯定消"(见 _fs_next_target)。
|
||||
slot_target 只作用于其中随机一旋(它按累计倍数结算,不占保底配额)。
|
||||
"""
|
||||
total_spins = FREE_SPINS_AWARDED
|
||||
fs_index = 0
|
||||
all_steps = []
|
||||
fs_total = 0.0
|
||||
sc_dist = FREESPIN_SCATTER.get(mode_code, FREESPIN_SCATTER['default'])
|
||||
budget = fs_budget
|
||||
# 炸弹大小按档位配:低倍局用小球(预算买得起多次"消除+爆"),高倍局用全量(2~1000 的大球)
|
||||
target_mul = float(slot_target) if isinstance(slot_target, (int, float)) else 0.0
|
||||
spin_bomb_max = None if target_mul >= FS_BIG_BOMB_TARGET else FS_SMALL_BOMB_MAX
|
||||
bias_slot = random.randrange(FREE_SPINS_AWARDED) if slot_target else -1
|
||||
# 保底配额:预算 ÷ 单次消除边际赔付再开方 = 这一局"买得起"几次消除
|
||||
quota = None if budget is None else max(1, int((max(0.0, budget) / FS_GUARANTEE_COST) ** 0.5))
|
||||
wins = 0 # 已消除的旋数(自然 + 保底)
|
||||
dry = 0 # 已经连续空转了几旋(兜底用)
|
||||
target = None if quota is None else _fs_next_target(0, 0, total_spins, quota)
|
||||
while fs_index < total_spins:
|
||||
fs_index += 1
|
||||
is_target_slot = (fs_index - 1) == bias_slot
|
||||
spin_bias = slot_target if is_target_slot else None
|
||||
forced = False
|
||||
if spin_bias is None and quota is not None:
|
||||
# 到点了 / 空转太久被软兜底抽中 → 本旋必补一次保底消除
|
||||
forced = target is not None and fs_index >= target
|
||||
if not forced and dry >= FS_DRY_FORCE_HARD:
|
||||
forced = True
|
||||
elif not forced and dry in FS_DRY_FORCE_PROB:
|
||||
forced = random.random() < FS_DRY_FORCE_PROB[dry]
|
||||
p = 1.0 if forced else 0.0
|
||||
else:
|
||||
p = FS_SPIN_WIN_PROB
|
||||
sc = _weighted_choice(sc_dist)
|
||||
# 已到 30 旋上限:这一旋不再落 3+ 个 S —— 否则会写出"extraSpins=5 但没有旋可加"的
|
||||
# retrigger_info(玩家白看一次 +5,前端还得自己夹 30)。上限之前的那一旋照常触发第 3 次 +5。
|
||||
if total_spins >= MAX_FREE_SPINS:
|
||||
sc = min(sc, SCATTER_RETRIGGER_TRIGGER - 1)
|
||||
spin_steps, spin_score, acc, sc_count = _spin(
|
||||
mode_code, spin_bias, acc, is_free_spin=True, target_scatter=sc,
|
||||
win_prob=p, continue_prob=FS_CASCADE_CONTINUE_PROB,
|
||||
cascade_max=FS_CASCADE_MAX,
|
||||
bomb_prob=FS_SCREEN_BOMB_PROB, bomb_max=spin_bomb_max,
|
||||
max_bombs=FS_MAX_BOMBS_PER_SPIN)
|
||||
if sc_count >= SCATTER_RETRIGGER_TRIGGER and total_spins < MAX_FREE_SPINS:
|
||||
total_spins += FREE_SPIN_RETRIGGER_COUNT
|
||||
# 目标大奖旋的花费由目标额单独控制,不占用保底预算
|
||||
if budget is not None and not is_target_slot:
|
||||
budget -= spin_score
|
||||
for s in spin_steps:
|
||||
s['free_spin_id'] = fs_index
|
||||
fs_total += spin_score
|
||||
all_steps.extend(spin_steps)
|
||||
# 进度:消了(不管自然还是保底)就重新抽下一次的位置 —— 整局消除次数仍由配额决定
|
||||
if spin_bias is None and quota is not None:
|
||||
if any(float(s['base_score']) > 0 for s in spin_steps):
|
||||
wins += 1
|
||||
dry = 0
|
||||
target = _fs_next_target(fs_index, wins, total_spins, quota)
|
||||
else:
|
||||
dry += 1
|
||||
return all_steps, fs_total
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# 主入口
|
||||
# ====================================================================
|
||||
|
||||
def generate_round(box, bias=None):
|
||||
"""
|
||||
生成一个完整回合。box: {'box_id','box_type',...} bias: None/'zero'/目标倍率(float)
|
||||
返回 (round_data, steps, orn_id)
|
||||
"""
|
||||
mode_code = box['box_type']
|
||||
buy_fs = mode_code == 'buy_fs'
|
||||
# 目标高于"基础旋(无炸弹)能赔到的上限"时,只能生成自然触发 FS 的回合来装这个桶:
|
||||
# normal/ante 的 100x/150x/200x 桶没有别的来源(基础旋不出炸弹,最多 ~60x)。
|
||||
must_free_spins = isinstance(bias, (int, float)) and not buy_fs \
|
||||
and float(bias) >= FS_ONLY_TARGET_MUL
|
||||
is_fs = False
|
||||
if buy_fs:
|
||||
is_fs = True # 购买模式保证进 FS(互斥:无底注效果)
|
||||
elif must_free_spins:
|
||||
is_fs = True
|
||||
elif mode_code in BASE_FS_TRIGGER_PROB and bias is None:
|
||||
is_fs = random.random() < BASE_FS_TRIGGER_PROB[mode_code]
|
||||
|
||||
if is_fs:
|
||||
# 触发盘:三模式都固定/摇出 SCATTER,且**不出炸弹**(炸弹只存在于免费旋转内)
|
||||
trigger_sc = 4 if buy_fs else _weighted_choice(FS_TRIGGER_SCATTER)
|
||||
base_steps, base_total, _, _ = _spin(
|
||||
mode_code, None, 0, is_free_spin=False, target_scatter=trigger_sc,
|
||||
allow_bomb=False)
|
||||
# FS 预算:买 FS / 高倍桶(bias=桶目标)拆成「保底消除预算 + 一旋大奖」;
|
||||
# normal/ante 自然摇出来的 FS(bias=None)没有价格可拆 → 只给同档保底预算、不植大奖旋,
|
||||
# 整局赔付由自然消除 + 炸弹累计决定(实测约 80x、5 旋有消除,落在模式自己的桶里)。
|
||||
fs_budget, slot_target = None, None
|
||||
if isinstance(bias, (int, float)) and bias > 0:
|
||||
budget = max(0.0, float(bias) - float(base_total))
|
||||
if budget <= FS_FLOOR_BUDGET_CAP:
|
||||
fs_budget = budget # 低倍局:全靠保底小消
|
||||
else:
|
||||
fs_budget = FS_FLOOR_BUDGET_CAP # 保底小消是平价结算(×1),预算可以给足
|
||||
slot_target = budget - fs_budget # 其余给"大奖旋"(它按累计倍数结算)
|
||||
elif not buy_fs:
|
||||
fs_budget = FS_FLOOR_BUDGET_CAP
|
||||
# 炸弹累计器仅免费旋转内生效:FS 会话从 0 起累,触发盘炸弹不带入
|
||||
fs_steps, fs_total = _free_spin_session(
|
||||
mode_code, slot_target=slot_target, acc=0, fs_budget=fs_budget)
|
||||
steps = base_steps + fs_steps
|
||||
else:
|
||||
nearmiss = _weighted_choice(BASE_NEARMISS_SCATTER.get(mode_code, {0: 1.0}))
|
||||
steps, _, _, _ = _spin(mode_code, bias, 0,
|
||||
is_free_spin=False, target_scatter=nearmiss)
|
||||
|
||||
# 理论总分(落桶用,可到 1370+)
|
||||
raw_total = round(sum(s['win_amount'] for s in steps), 2)
|
||||
orn_id = _bucket(raw_total)
|
||||
|
||||
# 发放截断 + 口径重写:累计到 1000 即终止,动画=到账零偏差。
|
||||
# win_amount = **本旋累计到本步的赢额**(该旋末步就是这一旋的总额);
|
||||
# spin_win = **本局累计到本步的赢额**(最后一步 = 整局实付 = totalWin)
|
||||
# 前端因此不需要自己相加:本步增量 = 与上一步相减,本旋总额 = 本旋末步的 win_amount。
|
||||
raw_increments = [s['win_amount'] for s in steps]
|
||||
cumul, spin_cumul, capped_steps, hit = 0.0, {}, [], False
|
||||
for s, inc in zip(steps, raw_increments):
|
||||
if cumul + inc >= MAX_WIN: # 封顶:本步补足到 1000 并终止
|
||||
inc = round(MAX_WIN - cumul, 2)
|
||||
s['win_capped'] = 1
|
||||
s['is_final'] = 1
|
||||
hit = True
|
||||
key = s['free_spin_id']
|
||||
spin_cumul[key] = round(spin_cumul.get(key, 0.0) + inc, 2)
|
||||
cumul = round(cumul + inc, 2)
|
||||
s['win_amount'] = spin_cumul[key] # 本旋累计(末步即该旋总额)
|
||||
s['spin_win'] = cumul # 本局累计
|
||||
capped_steps.append(s)
|
||||
if hit:
|
||||
break
|
||||
if hit:
|
||||
steps = capped_steps
|
||||
paid_total = round(cumul, 2)
|
||||
|
||||
# 爆奖动画(小/中/大):**只有免费旋转里才判档** —— normal/ante 靠自然触发 FS、
|
||||
# buy_fs 本身即 FS;基础旋(free_spin_id=0)一律 null,不播大奖动画。
|
||||
# 单旋赢额 = 该旋末帧的 win_amount;旋与旋互不影响,同档一局可多次播放。
|
||||
for s in steps:
|
||||
if s['is_final'] and s['free_spin_id'] > 0:
|
||||
s['bigwin_level'] = bigwin_level(mode_code, float(s['win_amount']))
|
||||
|
||||
fs_cnt = len({s['free_spin_id'] for s in steps if s['free_spin_id'] > 0})
|
||||
rd = {
|
||||
'box_id': box['box_id'], 'ornament_id': orn_id,
|
||||
'actual_mul': round(paid_total, 4), # 实付倍率 ≤1000
|
||||
'free_spin_count': fs_cnt,
|
||||
'total_cascades': len(steps),
|
||||
}
|
||||
return rd, steps, orn_id
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# 分布补偿:两阶段填桶
|
||||
# ====================================================================
|
||||
|
||||
def _bias_for(orn_id):
|
||||
if orn_id == 1:
|
||||
return 'zero'
|
||||
o = next(x for x in ORNAMENTS if x['id'] == orn_id)
|
||||
return float(o['expect_mul'])
|
||||
|
||||
|
||||
def _calibrate_bias(box, orn_id, samples=8):
|
||||
"""给某个桶在线找一个"更容易撞上"的 bias(只改传给生成器的目标值,不动任何规则)。
|
||||
|
||||
为什么需要:`_bias_for` 用的是桶的名义 expect_mul,但 buy_fs 的整局赔付被 FS 会话抬高了 ——
|
||||
实测 bias=10(桶 7 名义 10x)时实际赔付 min 14 / 中位 58,**永远撞不进 8~12**,命中率仅 3%,
|
||||
stage2 只能白跑几万次(单箱 5~10 分钟)。这里拿几个候选值各试几局,挑命中率最高的那个。"""
|
||||
target = _bias_for(orn_id)
|
||||
best, best_hit = target, -1
|
||||
for mult in (1.0, 0.5, 0.3, 0.15, 0.08):
|
||||
b = target * mult
|
||||
hit = 0
|
||||
for _ in range(samples):
|
||||
_, _, oid = generate_round(box, b)
|
||||
hit += (oid == orn_id)
|
||||
if hit > best_hit:
|
||||
best, best_hit = b, hit
|
||||
if hit == samples: # 已经全中,不用再试更小/更大的
|
||||
break
|
||||
return best
|
||||
|
||||
|
||||
def fill_distribution(box, target_counts, max_rounds=None):
|
||||
"""阶段1 自由生成积累常见桶;阶段2 按缺额 bias 补桶。返回 (rounds, 实际分布)
|
||||
|
||||
阶段2 的尝试上限按 total×60:保底消除改成"随机间隔"之后,低倍桶(10x/16x)的命中率大约减半
|
||||
(同样的钱摊到整局、摊到累计倍数高的旋上更贵),尝试数不够会填不满池子(实测 ×30 时
|
||||
10x 桶只填到 178/297,×60 满池)。
|
||||
某个桶饿死时(试了 4 倍于它条数的次数还没填满)会重新校准它的 bias —— 只对慢桶生效,
|
||||
命中率本来就高的桶走原来的名义值,不额外花钱。"""
|
||||
gen = Counter()
|
||||
rounds = []
|
||||
total_target = sum(target_counts.values())
|
||||
bias_use = {} # orn_id -> 校准后的 bias
|
||||
tries = Counter() # orn_id -> 已尝试次数
|
||||
|
||||
def _accept(oid):
|
||||
if oid in target_counts and gen[oid] < target_counts[oid]:
|
||||
gen[oid] += 1
|
||||
return True
|
||||
return False
|
||||
|
||||
free_budget = min(total_target * 3, 12000)
|
||||
for _ in range(free_budget):
|
||||
if sum(gen.values()) >= total_target:
|
||||
break
|
||||
rd, steps, oid = generate_round(box, None)
|
||||
if _accept(oid):
|
||||
rounds.append((rd, steps))
|
||||
|
||||
max_att = (max_rounds or total_target * 60)
|
||||
for _ in range(max_att):
|
||||
deficit = [(oid, target_counts[oid] - gen[oid])
|
||||
for oid in target_counts if gen[oid] < target_counts[oid]]
|
||||
if not deficit:
|
||||
break
|
||||
# 按缺口加权随机选桶,避免“最难桶”饿死其他桶
|
||||
need = random.choices([d[0] for d in deficit],
|
||||
weights=[d[1] for d in deficit])[0]
|
||||
tries[need] += 1
|
||||
if need not in bias_use and tries[need] > target_counts[need] * 4:
|
||||
bias_use[need] = _calibrate_bias(box, need)
|
||||
rd, steps, oid = generate_round(box, bias_use.get(need, _bias_for(need)))
|
||||
if _accept(oid):
|
||||
rounds.append((rd, steps))
|
||||
|
||||
return rounds, dict(gen)
|
||||
@@ -0,0 +1,16 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "olympus"
|
||||
version = "0.1.0"
|
||||
description = "Olympus match-3 game engine with bomb accumulator rules."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
license = {text = "Proprietary"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["olympus*"]
|
||||
namespaces = false
|
||||
@@ -0,0 +1,240 @@
|
||||
-- ============================================================
|
||||
-- Olympus 电子游戏模块 全量建表 DDL
|
||||
-- 表前缀: game_olympus_ 游戏类型: OLYMPUS
|
||||
-- 设计对齐 sweet 体系,差异点:
|
||||
-- 1. 饰品桶最高 1000x(单局封顶 1000 倍基础下注)
|
||||
-- 2. step 表自带 accumulator_before/after(跨回合炸弹累计)与 win_capped
|
||||
-- 3. record 表自带结算/补发字段(settled 等),无需后期 ALTER
|
||||
-- 4. 模式仅三种: normal / ante / buy_fs(互斥,无 super 模式)
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 箱子配置(模式 × 下注档位)
|
||||
CREATE TABLE IF NOT EXISTS game_olympus_box (
|
||||
box_id BIGINT NOT NULL COMMENT 'PK',
|
||||
game_type VARCHAR(16) NOT NULL DEFAULT 'OLYMPUS',
|
||||
box_type VARCHAR(32) NOT NULL COMMENT 'normal|ante|buy_fs',
|
||||
bet DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '基础下注',
|
||||
name VARCHAR(50) DEFAULT NULL,
|
||||
price DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '实际扣费 = bet × CM (normal=1/ante=2/buy_fs=20)',
|
||||
total_ornaments INT NOT NULL DEFAULT '0' COMMENT '总回合数',
|
||||
rtp DECIMAL(5,4) NOT NULL DEFAULT '0.8000' COMMENT '目标返奖率',
|
||||
rtp_converge_ratio DECIMAL(4,3) NULL COMMENT '一轮收敛回合占比,空=默认0.15',
|
||||
sort INT DEFAULT '0',
|
||||
status VARCHAR(1) DEFAULT '1' COMMENT '1启用 0禁用',
|
||||
del_flag VARCHAR(1) DEFAULT '0',
|
||||
remark VARCHAR(200) DEFAULT NULL,
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (box_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus箱子配置';
|
||||
|
||||
-- 2. 饰品桶定义(0x ~ 1000x,共17档)
|
||||
CREATE TABLE IF NOT EXISTS game_olympus_ornament (
|
||||
id BIGINT NOT NULL COMMENT 'PK 1-17',
|
||||
game_type VARCHAR(16) NOT NULL DEFAULT 'OLYMPUS',
|
||||
expect_mul DECIMAL(8,2) NOT NULL COMMENT '期望倍率(最高1000)',
|
||||
name VARCHAR(50) DEFAULT NULL,
|
||||
range_low DECIMAL(8,2) DEFAULT NULL COMMENT '落桶区间下限(含)',
|
||||
range_high DECIMAL(8,2) DEFAULT NULL COMMENT '落桶区间上限(含)',
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus饰品桶定义';
|
||||
|
||||
-- 3. 箱子-饰品桶分布(库存配额,剩余 = count - used)
|
||||
-- audience 多身份爆率:每种身份(common/anchor/robot)一套独立分布,回合库存池共享
|
||||
CREATE TABLE IF NOT EXISTS game_olympus_box_ornament (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
box_id BIGINT NOT NULL,
|
||||
ornament_id INT NOT NULL,
|
||||
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT '身份维度 common普通/anchor主播/robot机器人',
|
||||
count INT NOT NULL DEFAULT '0' COMMENT '分配回合数',
|
||||
used INT NOT NULL DEFAULT '0' COMMENT '已消耗回合数,剩余=count-used',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_box_ornament_aud (box_id, ornament_id, audience)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus箱子饰品分布(多身份)';
|
||||
|
||||
-- 4. 预生成回合
|
||||
CREATE TABLE IF NOT EXISTS game_olympus_round (
|
||||
round_id BIGINT NOT NULL COMMENT 'PK',
|
||||
box_id BIGINT NOT NULL,
|
||||
ornament_id INT NOT NULL COMMENT '落入的饰品桶 1-17',
|
||||
actual_mul DECIMAL(10,4) NOT NULL DEFAULT '0.0000' COMMENT '实际总倍率(≤1000)',
|
||||
free_spin_count INT NOT NULL DEFAULT '0' COMMENT '免费旋转总次数(15+重触发×5)',
|
||||
total_cascades INT NOT NULL DEFAULT '0' COMMENT '总步数',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (round_id),
|
||||
INDEX idx_box_ornament (box_id, ornament_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus预生成回合';
|
||||
|
||||
-- 5. 回合步骤(级联/翻滚明细)
|
||||
CREATE TABLE IF NOT EXISTS game_olympus_step (
|
||||
id BIGINT NOT NULL COMMENT 'PK',
|
||||
round_id BIGINT NOT NULL,
|
||||
free_spin_id INT NOT NULL DEFAULT '0' COMMENT '0=基础旋转, >0=第N次免费旋转',
|
||||
cascade_seq INT NOT NULL DEFAULT '0' COMMENT '本旋转内第几次翻滚',
|
||||
grid_state JSON DEFAULT NULL COMMENT '6×5网格最终状态',
|
||||
multiplier_detail JSON DEFAULT NULL COMMENT '炸弹符号 [{"pos":int,"val":int},...]',
|
||||
symbol_links JSON DEFAULT NULL COMMENT '中奖明细 [{"symbol","count","loc",...}]',
|
||||
accumulated_symbols JSON DEFAULT NULL COMMENT '本旋转累计消除符号 {"CROWN":9,...}',
|
||||
spin_winners JSON DEFAULT NULL COMMENT '获胜者面板:本旋逐波追加 [{"symbol","count","win"},...] 新旋清空',
|
||||
removed_positions JSON DEFAULT NULL COMMENT '消除位置 [pos,...]',
|
||||
drop_data JSON DEFAULT NULL COMMENT '下落数据 [{"from":int,"to":int,"symbol":"A"},...]',
|
||||
fill_data JSON DEFAULT NULL COMMENT '填充数据 [{"pos":int,"symbol":"A"},...]',
|
||||
scatter_trigger JSON DEFAULT NULL COMMENT '基础盘触发免费旋转 {scatterCount,freeSpins(15),scatterWin}',
|
||||
retrigger_info JSON DEFAULT NULL COMMENT '免费旋转重触发 {scatterCount,extraSpins(5),scatterWin}',
|
||||
base_score DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本步基础分(未乘炸弹)',
|
||||
win_amount DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本步赢额(回合末帧含炸弹结算)',
|
||||
accumulator_before DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本步前累计炸弹倍数',
|
||||
accumulator_after DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本步后累计炸弹倍数(跨回合传递)',
|
||||
spin_win DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '整局累计赢额',
|
||||
win_capped TINYINT NOT NULL DEFAULT '0' COMMENT '本步赢额被1000x封顶截断 0否1是',
|
||||
bigwin_level VARCHAR(16) DEFAULT NULL COMMENT '爆奖档位(小/中/大,只在免费旋转里判档,按单旋赢额盖在该旋末帧,同档可多次): small/middle/big',
|
||||
is_final TINYINT DEFAULT '0' COMMENT '本旋转末帧',
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_round (round_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus回合步骤';
|
||||
|
||||
-- 6. 游戏记录(含结算/补发字段,建表即带全)
|
||||
CREATE TABLE IF NOT EXISTS game_olympus_record (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
user_id BIGINT NOT NULL,
|
||||
box_id BIGINT NOT NULL,
|
||||
round_id BIGINT NOT NULL,
|
||||
cost DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '扣费 = bet × CM',
|
||||
total_win DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '总赢额(结算时填,≤bet×1000)',
|
||||
settled TINYINT NOT NULL DEFAULT '0' COMMENT '是否已结算 0未1已(幂等守卫)',
|
||||
mode_type VARCHAR(20) DEFAULT NULL COMMENT '游戏模式 normal/ante/buy_fs',
|
||||
auto_dismantle TINYINT DEFAULT 0 COMMENT '开局自动分解快照 0关1开',
|
||||
reward_type VARCHAR(10) DEFAULT NULL COMMENT '发放类型 gold/ornament',
|
||||
settled_win DECIMAL(10,2) DEFAULT 0.00 COMMENT '本局价值(金币金额=饰品价格)',
|
||||
prize_id BIGINT DEFAULT NULL COMMENT '饰品ID game_olympus_prize_ornament.id',
|
||||
prize_name VARCHAR(100) DEFAULT NULL COMMENT '饰品名称',
|
||||
prize_image_url VARCHAR(500) DEFAULT NULL COMMENT '饰品图片URL',
|
||||
prize_level_img VARCHAR(500) DEFAULT NULL COMMENT '品质图片URL',
|
||||
settle_source VARCHAR(20) DEFAULT NULL COMMENT '结算来源 start=开局已结算(动画还在播) / finish=末帧播完(已结束,进战绩列表)',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_user_time (user_id, create_time),
|
||||
INDEX idx_box_time (box_id, create_time),
|
||||
INDEX idx_settled_time (settled, create_time)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='Olympus游戏记录';
|
||||
|
||||
-- 7. 用户统计
|
||||
CREATE TABLE IF NOT EXISTS game_olympus_user (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
user_id BIGINT NOT NULL,
|
||||
total_spins BIGINT NOT NULL DEFAULT '0',
|
||||
total_cost DECIMAL(12,2) NOT NULL DEFAULT '0.00',
|
||||
total_win DECIMAL(12,2) NOT NULL DEFAULT '0.00',
|
||||
total_free_spins INT NOT NULL DEFAULT '0',
|
||||
max_win_mul DECIMAL(10,4) NOT NULL DEFAULT '0.0000' COMMENT '历史最高倍率(≤1000)',
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_user (user_id)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='Olympus用户统计';
|
||||
|
||||
-- 8. 每日盈亏
|
||||
CREATE TABLE IF NOT EXISTS game_olympus_win (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
date DATE NOT NULL,
|
||||
total_spins BIGINT NOT NULL DEFAULT '0',
|
||||
total_cost DECIMAL(14,2) NOT NULL DEFAULT '0.00',
|
||||
total_win DECIMAL(14,2) NOT NULL DEFAULT '0.00',
|
||||
net_profit DECIMAL(14,2) NOT NULL DEFAULT '0.00',
|
||||
player_count INT NOT NULL DEFAULT '0',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_date (date)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='Olympus每日盈亏';
|
||||
|
||||
-- 9. 返奖饰品定义(展示用饰品,按赢额区间匹配)
|
||||
CREATE TABLE IF NOT EXISTS game_olympus_prize_ornament (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
game_type VARCHAR(16) NOT NULL DEFAULT 'OLYMPUS',
|
||||
name VARCHAR(100) DEFAULT NULL COMMENT '饰品名称',
|
||||
image_url VARCHAR(500) DEFAULT NULL COMMENT '饰品图片URL',
|
||||
grade INT DEFAULT NULL COMMENT '品质等级 1-5',
|
||||
exterior_name VARCHAR(100) DEFAULT NULL COMMENT '品质名称 崭新出厂/略有磨损/久经沙场/破损不堪/战痕累累',
|
||||
sort INT DEFAULT '0',
|
||||
status VARCHAR(1) DEFAULT '1',
|
||||
is_fallback TINYINT DEFAULT '0' COMMENT '兜底饰品 1是(仅展示不覆盖金额) 0否',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='Olympus返奖饰品';
|
||||
|
||||
-- 10. 返奖饰品区间(与 prize_ornament 一一对应,区间连续覆盖)
|
||||
CREATE TABLE IF NOT EXISTS game_olympus_prize_range (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
prize_id BIGINT NOT NULL COMMENT 'game_olympus_prize_ornament.id',
|
||||
win_low DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '返奖区间下限(含)',
|
||||
win_high DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '返奖区间上限(含)',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_prize (prize_id)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='Olympus返奖区间';
|
||||
|
||||
-- 11. 箱子饰品分布模板(抽干补桶依据,按身份区分)
|
||||
CREATE TABLE IF NOT EXISTS game_olympus_box_template (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
box_id BIGINT NOT NULL COMMENT '所属箱子ID',
|
||||
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT '身份维度 common/anchor/robot',
|
||||
name VARCHAR(100) NOT NULL COMMENT '模板名称(同一箱子+身份内唯一)',
|
||||
total_rounds INT NOT NULL DEFAULT 0 COMMENT '总回合数 = items 各 count 之和',
|
||||
items_json TEXT NOT NULL COMMENT '饰品分布JSON: [{"ornamentId":1,"count":100},...]',
|
||||
is_active TINYINT NOT NULL DEFAULT 0 COMMENT '是否生效模板(抽干补桶依据) 1是 0否',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_box_aud_name (box_id, audience, name),
|
||||
KEY idx_box_aud_active (box_id, audience, is_active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus箱子饰品分布模板(多身份)';
|
||||
|
||||
-- 12. 奖池水位调整日志(注水/抽水/重置审计)
|
||||
CREATE TABLE IF NOT EXISTS game_olympus_reward_log (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
box_id BIGINT DEFAULT NULL COMMENT '箱子ID(批量/重置为汇总,可空)',
|
||||
box_name VARCHAR(100) DEFAULT NULL COMMENT '箱子名/汇总描述',
|
||||
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT '身份 common/anchor/robot/white',
|
||||
type TINYINT NOT NULL COMMENT '1注水 2抽水 3重置',
|
||||
amount DECIMAL(14,2) NOT NULL DEFAULT '0.00' COMMENT '调整金额(绝对值)',
|
||||
balance_before DECIMAL(14,2) DEFAULT NULL COMMENT '调整前水位',
|
||||
balance_after DECIMAL(14,2) DEFAULT NULL COMMENT '调整后水位',
|
||||
pool_balance DECIMAL(14,2) DEFAULT NULL COMMENT '操作后总池余额',
|
||||
operator VARCHAR(64) DEFAULT NULL COMMENT '操作人',
|
||||
remark VARCHAR(200) DEFAULT NULL COMMENT '备注',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_create (create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Olympus奖池水位调整日志';
|
||||
|
||||
-- ============================================================
|
||||
-- 种子数据:饰品桶 1-17(0x ~ 1000x)
|
||||
-- ============================================================
|
||||
INSERT INTO game_olympus_ornament (id, game_type, expect_mul, name, range_low, range_high) VALUES
|
||||
(1, 'OLYMPUS', 0.00, '0x', 0.00, 0.00),
|
||||
(2, 'OLYMPUS', 0.50, '0.5x', 0.01, 1.00),
|
||||
(3, 'OLYMPUS', 1.50, '1.5x', 1.00, 2.00),
|
||||
(4, 'OLYMPUS', 2.50, '2.5x', 2.00, 3.00),
|
||||
(5, 'OLYMPUS', 4.00, '4x', 3.00, 5.00),
|
||||
(6, 'OLYMPUS', 6.50, '6.5x', 5.00, 8.00),
|
||||
(7, 'OLYMPUS', 10.00, '10x', 8.00, 12.00),
|
||||
(8, 'OLYMPUS', 16.00, '16x', 12.00, 20.00),
|
||||
(9, 'OLYMPUS', 25.00, '25x', 20.00, 30.00),
|
||||
(10, 'OLYMPUS', 40.00, '40x', 30.00, 50.00),
|
||||
(11, 'OLYMPUS', 60.00, '60x', 50.00, 70.00),
|
||||
(12, 'OLYMPUS', 100.00, '100x', 70.00, 130.00),
|
||||
(13, 'OLYMPUS', 150.00, '150x', 130.00, 170.00),
|
||||
(14, 'OLYMPUS', 200.00, '200x', 170.00, 230.00),
|
||||
(15, 'OLYMPUS', 300.00, '300x', 230.00, 370.00),
|
||||
(16, 'OLYMPUS', 500.00, '500x', 370.00, 630.00),
|
||||
(17, 'OLYMPUS', 1000.00, '1000x', 630.00, 1370.00)
|
||||
ON DUPLICATE KEY UPDATE expect_mul=VALUES(expect_mul), name=VALUES(name),
|
||||
range_low=VALUES(range_low), range_high=VALUES(range_high);
|
||||
|
||||
-- ============================================================
|
||||
-- 附:Java 服务上线时执行(先不执行,接入后台时再放开)
|
||||
-- ============================================================
|
||||
-- 维护开关
|
||||
-- INSERT INTO sys_config (config_name, config_key, config_value, config_type, create_by, create_time, remark)
|
||||
-- VALUES ('Olympus维护', 'olympusMaintenance', '0', 'Y', 'admin', NOW(), 'Olympus维护开关 0正常 1维护');
|
||||
|
||||
-- 未结算兜底补发定时任务(系统监控>定时任务)
|
||||
-- INSERT INTO sys_job (job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark)
|
||||
-- VALUES ('Olympus未结算补发', 'OLYMPUS', 'olympusSettleTask.scheduledSettle()', '0 */10 * * * ?', '1', '0', '0', 'admin', NOW(), '每10分钟扫描超35分钟未结算记录并补发金币/饰品');
|
||||
@@ -0,0 +1,176 @@
|
||||
"""复刻 Java 抽回合链路,模拟每个箱子抽完一个 cycle 的实时 RTP。
|
||||
|
||||
复刻的 Java 逻辑(OlympusRoundPicker / OlympusGameService / OlympusDistributionService):
|
||||
1. 固定池 fixedSum = Σ 配比 count(按身份),N = round(fixedSum/(1-收敛比例)),
|
||||
fixedRounds = round(N×(1-收敛比例));固定阶段 drawn < fixedRounds
|
||||
2. 固定阶段:在"remaining>0 且 expect_mul×bet ≤ payoutCap"的桶里按 remaining 加权抽;
|
||||
桶内取 40 条候选,优先取 actual_mul×bet ≤ payoutCap 的(取不到就退回候选第一条);该桶 used+1
|
||||
3. 收敛阶段:desiredNextMul = (N×目标倍率 − 已派彩) / (N − drawn),
|
||||
取期望最接近 desired 的 3 个桶(随机其一),桶内取 actual_mul 最接近 min(desired, capMul) 的一条
|
||||
(优先 ≤ capMul),不消耗 used
|
||||
4. 水位:payoutCap = max(0, 水位 + 本局成本) × rtp;水位 += 成本 − 派彩(白号不参与水位 → 无上限)
|
||||
5. drawn ≥ N → cycle 结束:resetCycle + refillFromConfig(有生效模板则按模板重写 count,否则 used 归零)
|
||||
|
||||
用法:
|
||||
python simulate_cycle.py # 全部 30 箱 × common 身份,跑 3 个 cycle
|
||||
python simulate_cycle.py --cycles 5 --audience white
|
||||
python simulate_cycle.py --box 21
|
||||
"""
|
||||
import argparse
|
||||
import random
|
||||
from collections import defaultdict
|
||||
|
||||
import olympus_db as db
|
||||
from olympus.config import ORNAMENTS, MODES, payout_expect
|
||||
|
||||
# 抽回合的 payoutCap 过滤用**名义** expect_mul(对齐 Java OlympusRoundPicker,偏保守);
|
||||
# 统计里的"名义 RTP"用 payout_expect(对齐后台「目标RTP」列的封顶修正口径)。
|
||||
_ORN_MUL = {o['id']: o['expect_mul'] for o in ORNAMENTS}
|
||||
_ORN_PAY = {o['id']: payout_expect(o['id']) for o in ORNAMENTS}
|
||||
CANDIDATE_LIMIT = 40
|
||||
|
||||
|
||||
def load_boxes(box_id=None):
|
||||
with db.get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
sql = 'SELECT box_id, box_type, bet, rtp, rtp_converge_ratio, total_ornaments FROM game_olympus_box'
|
||||
if box_id:
|
||||
sql += ' WHERE box_id=%s' % int(box_id)
|
||||
cur.execute(sql + ' ORDER BY box_id')
|
||||
boxes = [dict(zip(('box_id', 'box_type', 'bet', 'rtp', 'cr', 'total'), r)) for r in cur.fetchall()]
|
||||
cur.execute('SELECT box_id, ornament_id, audience, count FROM game_olympus_box_ornament')
|
||||
cfg = defaultdict(dict)
|
||||
for b, o, a, c in cur.fetchall():
|
||||
cfg[(int(b), a)][int(o)] = int(c)
|
||||
cur.execute('SELECT box_id, ornament_id, actual_mul FROM game_olympus_round')
|
||||
rounds = defaultdict(lambda: defaultdict(list))
|
||||
for b, o, m in cur.fetchall():
|
||||
rounds[int(b)][int(o)].append(float(m))
|
||||
# 生效模板(抽完重填的依据)
|
||||
cur.execute('''SELECT box_id, audience, items_json FROM game_olympus_box_template
|
||||
WHERE is_active=1 ORDER BY id''')
|
||||
tpl = {}
|
||||
import json
|
||||
for b, a, items in cur.fetchall():
|
||||
tpl[(int(b), a)] = {int(i['ornamentId']): int(i['count'])
|
||||
for i in json.loads(items) if int(i.get('count', 0)) > 0}
|
||||
for b in boxes:
|
||||
b['cm'] = float(MODES[b['box_type']].cost_multiplier)
|
||||
return boxes, cfg, rounds, tpl
|
||||
|
||||
|
||||
def _weighted(cands, weights):
|
||||
total = sum(weights)
|
||||
r = random.uniform(0, total)
|
||||
acc = 0.0
|
||||
for c, w in zip(cands, weights):
|
||||
acc += w
|
||||
if r <= acc:
|
||||
return c
|
||||
return cands[-1]
|
||||
|
||||
|
||||
def simulate(box, cfg, rounds, tpl, audience, cycles=3, cr_override=None):
|
||||
cm, rtp = box['cm'], float(box['rtp'])
|
||||
cr = float(cr_override if cr_override is not None else (box['cr'] or 0.15))
|
||||
fixed_sum = sum(cfg.values())
|
||||
n_total = round(fixed_sum / (1.0 - cr))
|
||||
fixed_rounds = round(n_total * (1.0 - cr))
|
||||
target_mul = rtp * cm
|
||||
capped = (audience != 'white') # 白号 payoutCap=null
|
||||
stats = []
|
||||
counts = dict(cfg)
|
||||
cumulative = {'drawn': 0, 'mul': 0.0}
|
||||
balance = 0.0 # 箱子水位:跨 cycle 持久(= Σ投注 − Σ返奖)
|
||||
for cyc in range(1, cycles + 1):
|
||||
used = defaultdict(int)
|
||||
drawn, mul_sum = 0, 0.0
|
||||
rtp_fixed = None
|
||||
while drawn < n_total:
|
||||
cap_mul = max(0.0, balance + cm) * rtp if capped else float('inf')
|
||||
remaining = {o: max(0, c - used[o]) for o, c in counts.items()}
|
||||
if drawn < fixed_rounds and sum(remaining.values()) > 0:
|
||||
elig = [o for o, r in remaining.items()
|
||||
if r > 0 and _ORN_MUL[o] <= cap_mul] or \
|
||||
[o for o, r in remaining.items() if r > 0]
|
||||
orn = _weighted(elig, [remaining[o] for o in elig])
|
||||
pool = rounds[box['box_id']].get(orn) or [0.0]
|
||||
cands = random.sample(pool, min(CANDIDATE_LIMIT, len(pool)))
|
||||
mul = next((m for m in cands if m <= cap_mul), cands[0])
|
||||
used[orn] += 1
|
||||
else:
|
||||
desired = max(0.0, (n_total * target_mul - mul_sum) / max(1, n_total - drawn))
|
||||
allowed = [o for o, c in counts.items() if _ORN_MUL[o] <= cap_mul] or list(counts)
|
||||
allowed.sort(key=lambda o: abs(_ORN_MUL[o] - desired))
|
||||
orn = random.choice(allowed[:3])
|
||||
pool = rounds[box['box_id']].get(orn) or [0.0]
|
||||
eff = min(desired, cap_mul)
|
||||
mul = sorted(pool, key=lambda m: (0 if m <= cap_mul else 1, abs(m - eff)))[0]
|
||||
drawn += 1
|
||||
mul_sum += mul
|
||||
balance += cm - mul
|
||||
cumulative['drawn'] += 1
|
||||
cumulative['mul'] += mul
|
||||
if drawn == fixed_rounds:
|
||||
rtp_fixed = mul_sum / drawn / cm
|
||||
# cycle 结束 → 按模板重填(无模板则 used 归零、count 不变)
|
||||
tpl_counts = tpl.get((box['box_id'], audience))
|
||||
refilled = '模板' if tpl_counts else 'used归零'
|
||||
if tpl_counts:
|
||||
counts = {o: c for o, c in tpl_counts.items() if c > 0}
|
||||
stats.append({
|
||||
'cycle': cyc,
|
||||
'rtp_fixed': rtp_fixed,
|
||||
'rtp_end': mul_sum / drawn / cm,
|
||||
'balance_end': balance,
|
||||
'refill': refilled,
|
||||
})
|
||||
return {
|
||||
'box_id': box['box_id'], 'box_type': box['box_type'], 'audience': audience,
|
||||
'rtp': rtp, 'cr': cr, 'n_total': n_total, 'fixed_rounds': fixed_rounds,
|
||||
'fixed_sum': fixed_sum, 'nominal': sum(c * _ORN_PAY.get(o, 0) for o, c in cfg.items()) / fixed_sum / cm,
|
||||
'cycles': stats,
|
||||
'cum_rtp': cumulative['mul'] / cumulative['drawn'] / cm,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--box', type=int)
|
||||
ap.add_argument('--audience', default='common')
|
||||
ap.add_argument('--cycles', type=int, default=3)
|
||||
ap.add_argument('--cr', type=float, default=None, help='覆盖收敛比例(测敏感度用,不写库)')
|
||||
ap.add_argument('--seed', type=int, default=2026)
|
||||
args = ap.parse_args()
|
||||
random.seed(args.seed)
|
||||
boxes, cfg, rounds, tpl = load_boxes(args.box)
|
||||
print('身份=%s cycle 数=%d seed=%d 收敛比例=%s' % (
|
||||
args.audience, args.cycles, args.seed,
|
||||
('箱配置' if args.cr is None else args.cr)))
|
||||
print('箱 模式 配比sum N 固定池 名义RTP 各cycle(固定池末→整轮末) 累计RTP')
|
||||
rows = []
|
||||
for b in boxes:
|
||||
c = cfg.get((b['box_id'], args.audience), {})
|
||||
if not c:
|
||||
continue
|
||||
r = simulate(b, c, rounds, tpl, args.audience, args.cycles, args.cr)
|
||||
rows.append(r)
|
||||
per = ' '.join('%.1f→%.1f' % (s['rtp_fixed'] * 100, s['rtp_end'] * 100) for s in r['cycles'])
|
||||
flag = '✓' if abs(r['cum_rtp'] - r['rtp']) < 0.03 else '✗'
|
||||
print('%2d %-7s %5d %4d %5d %6.1f%% %-34s %6.1f%% %s' % (
|
||||
r['box_id'], r['box_type'], r['fixed_sum'], r['n_total'], r['fixed_rounds'],
|
||||
r['nominal'] * 100, per, r['cum_rtp'] * 100, flag))
|
||||
bad = [r for r in rows if abs(r['cum_rtp'] - r['rtp']) >= 0.03]
|
||||
print('\n达标(±3个百分点): %d/%d' % (len(rows) - len(bad), len(rows)))
|
||||
if bad:
|
||||
print('未达标:', [(r['box_id'], round(r['cum_rtp'] * 100, 1), round(r['rtp'] * 100, 1)) for r in bad])
|
||||
if rows:
|
||||
r = rows[0]
|
||||
print('\n样例 box%d 逐 cycle 明细(%s):' % (r['box_id'], r['audience']))
|
||||
for s in r['cycles']:
|
||||
print(' cycle%d: 固定池结束 %.1f%% 整轮结束 %.1f%% 抽完水位=%.0f 倍底注 重填=%s' % (
|
||||
s['cycle'], s['rtp_fixed'] * 100, s['rtp_end'] * 100, s['balance_end'], s['refill']))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Olympus tests."""
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Olympus 引擎单元测试(unittest)。不依赖第三方库,可直接 `python tests/test_engine.py` 运行。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
# 支持直接 `python tests/test_engine.py` 运行(无需先安装为包)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from olympus import OlympusEngine, RuleViolation
|
||||
from olympus.config import MODES, SYMBOL_RULES
|
||||
from olympus.models import Bomb
|
||||
|
||||
|
||||
class TestSymbolRule(unittest.TestCase):
|
||||
"""符号赔付校验:8/10/12 三档取值正确。"""
|
||||
|
||||
def test_symbol_payout_tiers(self) -> None:
|
||||
crown = SYMBOL_RULES["CROWN"]
|
||||
self.assertEqual(crown.payout_for(7), Decimal("0"))
|
||||
self.assertEqual(crown.payout_for(8), Decimal("10"))
|
||||
self.assertEqual(crown.payout_for(9), Decimal("10"))
|
||||
self.assertEqual(crown.payout_for(10), Decimal("25"))
|
||||
self.assertEqual(crown.payout_for(11), Decimal("25"))
|
||||
self.assertEqual(crown.payout_for(12), Decimal("50"))
|
||||
self.assertEqual(crown.payout_for(30), Decimal("50"))
|
||||
|
||||
|
||||
class TestSingleCascade(unittest.TestCase):
|
||||
"""单回合单次消除:无炸弹时有效倍数为 1。"""
|
||||
|
||||
def test_one_cascade_no_bomb(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin()
|
||||
engine.record_cascade(symbols={"BLUE_GEM": list(range(8))}, bombs=[])
|
||||
spin = engine.end_spin()
|
||||
self.assertEqual(spin.base_win, Decimal("0.25"))
|
||||
self.assertEqual(spin.effective_multiplier, 1)
|
||||
self.assertAlmostEqual(float(spin.total_win), 0.25, places=2)
|
||||
|
||||
def test_one_cascade_with_bomb(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin()
|
||||
engine.record_cascade(
|
||||
symbols={"CROWN": [0, 1, 2, 7, 8, 9, 14, 15, 16]}, # 9 个 → 10x
|
||||
bombs=[Bomb("b1", 2)],
|
||||
)
|
||||
spin = engine.end_spin()
|
||||
self.assertEqual(spin.base_win, Decimal("10"))
|
||||
self.assertEqual(spin.effective_multiplier, 2)
|
||||
self.assertEqual(spin.total_win, Decimal("20"))
|
||||
|
||||
|
||||
class TestMultiCascadeWithinSpin(unittest.TestCase):
|
||||
"""一回合多次消除:炸弹只在整回合结束后统一引爆,本回合中间不逐次乘算。"""
|
||||
|
||||
def test_bomb_only_applies_after_all_cascades(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin()
|
||||
c1 = engine.record_cascade(
|
||||
symbols={"BLUE_GEM": list(range(8))}, bombs=[Bomb("m1", 3)]
|
||||
)
|
||||
c2 = engine.record_cascade(
|
||||
symbols={"GREEN_GEM": list(range(8, 16))}, bombs=[]
|
||||
)
|
||||
# 级联阶段自身的 base_win 不应被乘数放大
|
||||
self.assertEqual(c1.base_win, Decimal("0.25"))
|
||||
self.assertEqual(c2.base_win, Decimal("0.4"))
|
||||
spin = engine.end_spin()
|
||||
# 回合基础总赢额 = 两次消除之和(未乘算)
|
||||
self.assertEqual(spin.base_win, Decimal("0.65"))
|
||||
# 炸弹在回合结束后才统一生效
|
||||
self.assertEqual(spin.effective_multiplier, 3)
|
||||
self.assertEqual(spin.total_win, Decimal("1.95"))
|
||||
|
||||
def test_symbols_are_accumulated_across_cascades(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin()
|
||||
engine.record_cascade(symbols={"BLUE_GEM": list(range(8))}, bombs=[Bomb("m1", 3)])
|
||||
c2 = engine.record_cascade(symbols={"BLUE_GEM": list(range(8, 16))}, bombs=[])
|
||||
# 同一符号在本回合内跨级联累计记录(8 + 8 = 16)
|
||||
self.assertEqual(c2.accumulated_symbols.get("BLUE_GEM"), 16)
|
||||
spin = engine.end_spin()
|
||||
self.assertEqual(spin.accumulated_symbols.get("BLUE_GEM"), 16)
|
||||
|
||||
|
||||
class TestCrossSpinAccumulator(unittest.TestCase):
|
||||
"""炸弹累计器:仅免费旋转生效;基础旋转炸弹只乘本旋、不留存。"""
|
||||
|
||||
def test_base_spin_does_not_accumulate(self) -> None:
|
||||
"""基础旋转:炸弹乘本旋赢额,但不进累计器。"""
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin() # 默认基础旋转
|
||||
engine.record_cascade(symbols={"RED_GEM": list(range(8))}, bombs=[Bomb("m1", 5)])
|
||||
s1 = engine.end_spin()
|
||||
self.assertEqual(s1.effective_multiplier, 5) # 本旋生效
|
||||
self.assertEqual(s1.total_win, Decimal("5")) # 1 * 5
|
||||
self.assertEqual(s1.multiplier_after, 0) # 不留存
|
||||
# 第二次基础旋转:只吃自己的炸弹
|
||||
engine.begin_spin()
|
||||
engine.record_cascade(symbols={"PURPLE_GEM": list(range(8, 16))}, bombs=[Bomb("m2", 3)])
|
||||
s2 = engine.end_spin()
|
||||
self.assertEqual(s2.multiplier_before, 0)
|
||||
self.assertEqual(s2.effective_multiplier, 3)
|
||||
self.assertEqual(s2.total_win, Decimal("2.4")) # 0.8 * 3
|
||||
|
||||
def test_accumulator_carries_across_free_spins(self) -> None:
|
||||
"""免费旋转:炸弹并入累计器并跨旋传递。"""
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin(is_free_spin=True)
|
||||
engine.record_cascade(symbols={"RED_GEM": list(range(8))}, bombs=[Bomb("m1", 5)])
|
||||
s1 = engine.end_spin()
|
||||
self.assertEqual(s1.multiplier_before, 0)
|
||||
self.assertEqual(s1.multiplier_after, 5)
|
||||
self.assertEqual(s1.total_win, Decimal("5")) # 1 * 5
|
||||
|
||||
engine.begin_spin(is_free_spin=True)
|
||||
engine.record_cascade(symbols={"PURPLE_GEM": list(range(8, 16))}, bombs=[Bomb("m2", 3)])
|
||||
s2 = engine.end_spin()
|
||||
self.assertEqual(s2.multiplier_before, 5)
|
||||
self.assertEqual(s2.multiplier_after, 8)
|
||||
self.assertEqual(s2.total_win, Decimal("6.4")) # PURPLE_GEM 8个=0.8x, 0.8 * 8 = 6.4
|
||||
|
||||
def test_fs_spin_without_win_keeps_accumulator_unchanged(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin(is_free_spin=True)
|
||||
engine.record_cascade(symbols={"RED_GEM": list(range(8))}, bombs=[Bomb("m1", 5)])
|
||||
engine.end_spin()
|
||||
|
||||
# 第二次 FS 旋转没有任何消除(即使传入炸弹也无效,不计入累计)
|
||||
engine.begin_spin(is_free_spin=True)
|
||||
s2 = engine.end_spin()
|
||||
self.assertEqual(s2.base_win, Decimal("0"))
|
||||
self.assertEqual(s2.effective_multiplier, 0)
|
||||
self.assertEqual(s2.multiplier_after, 5) # 累计值保持不变
|
||||
|
||||
def test_bomb_discarded_when_cascade_has_no_win(self) -> None:
|
||||
"""无消除的旋转即使出现炸弹,也不应计入累计(消除是炸弹生效的前提)。"""
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin(is_free_spin=True)
|
||||
engine.record_cascade(symbols={}, bombs=[Bomb("m1", 10)])
|
||||
spin = engine.end_spin()
|
||||
self.assertEqual(spin.base_win, Decimal("0"))
|
||||
self.assertEqual(spin.multiplier_after, 0)
|
||||
|
||||
|
||||
class TestStateGuards(unittest.TestCase):
|
||||
"""状态机误用与非法输入的防御性校验。"""
|
||||
|
||||
def test_non_positive_bet_raises(self) -> None:
|
||||
with self.assertRaises(RuleViolation):
|
||||
OlympusEngine(bet="0")
|
||||
|
||||
def test_record_cascade_without_begin_raises(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
with self.assertRaises(RuleViolation):
|
||||
engine.record_cascade(symbols={"CROWN": list(range(8))})
|
||||
|
||||
def test_begin_spin_twice_raises(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin()
|
||||
with self.assertRaises(RuleViolation):
|
||||
engine.begin_spin()
|
||||
|
||||
def test_invalid_symbol_raises(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin()
|
||||
with self.assertRaises(RuleViolation):
|
||||
engine.record_cascade(symbols={"UNKNOWN": list(range(8))})
|
||||
|
||||
def test_below_min_win_count_raises(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin()
|
||||
with self.assertRaises(RuleViolation):
|
||||
engine.record_cascade(symbols={"CROWN": list(range(7))})
|
||||
|
||||
def test_duplicate_positions_raises(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin()
|
||||
with self.assertRaises(RuleViolation):
|
||||
engine.record_cascade(symbols={"CROWN": [0, 0, 1, 2, 3, 4, 5, 6]})
|
||||
|
||||
def test_invalid_bomb_value_raises(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin()
|
||||
with self.assertRaises(RuleViolation):
|
||||
engine.record_cascade(symbols={"CROWN": list(range(8))}, bombs=[Bomb("m1", 7)])
|
||||
|
||||
def test_inconsistent_bomb_redefinition_raises(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin()
|
||||
engine.record_cascade(symbols={"CROWN": list(range(8))}, bombs=[Bomb("m1", 5)])
|
||||
with self.assertRaises(RuleViolation):
|
||||
engine.record_cascade(symbols={}, bombs=[Bomb("m1", 10)])
|
||||
|
||||
def test_reset_session_while_spin_active_raises(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin()
|
||||
with self.assertRaises(RuleViolation):
|
||||
engine.reset_session()
|
||||
|
||||
|
||||
class TestGameModes(unittest.TestCase):
|
||||
"""模式互斥与下注档位。"""
|
||||
|
||||
def test_mode_bet_tiers_and_prices(self) -> None:
|
||||
# 普通:1 3 5 10 20 30 50 100 200 300(扣费=下注)
|
||||
normal = MODES["normal"]
|
||||
self.assertEqual(normal.base_bets, (1, 3, 5, 10, 20, 30, 50, 100, 200, 300))
|
||||
self.assertEqual(normal.price_for(20), Decimal("20"))
|
||||
# 底注:扣费 = 2 6 10 20 40 60 100 200 400 600
|
||||
ante = MODES["ante"]
|
||||
self.assertEqual(
|
||||
[int(ante.price_for(b)) for b in ante.base_bets],
|
||||
[2, 6, 10, 20, 40, 60, 100, 200, 400, 600],
|
||||
)
|
||||
# 购买免费旋转:扣费 = 50 150 250 500 1000 1500 2500 5000 10000 15000(10 档,50 倍)
|
||||
buy = MODES["buy_fs"]
|
||||
self.assertEqual(
|
||||
[int(buy.price_for(b)) for b in buy.base_bets],
|
||||
[50, 150, 250, 500, 1000, 1500, 2500, 5000, 10000, 15000],
|
||||
)
|
||||
|
||||
def test_mode_mutual_exclusion(self) -> None:
|
||||
# 底注模式禁止购买免费旋转
|
||||
ante_engine = OlympusEngine(bet="1", mode="ante")
|
||||
self.assertFalse(ante_engine.buy_free_spins_allowed())
|
||||
self.assertTrue(ante_engine.ante_boost_active())
|
||||
# 购买免费旋转模式禁用底注效果
|
||||
buy_engine = OlympusEngine(bet="1", mode="buy_fs")
|
||||
self.assertFalse(buy_engine.ante_boost_active())
|
||||
self.assertFalse(buy_engine.buy_free_spins_allowed())
|
||||
self.assertTrue(buy_engine.mode.guaranteed_free_spins)
|
||||
# 普通模式允许购买
|
||||
normal_engine = OlympusEngine(bet="1", mode="normal")
|
||||
self.assertTrue(normal_engine.buy_free_spins_allowed())
|
||||
self.assertFalse(normal_engine.ante_boost_active())
|
||||
|
||||
def test_engine_cost_uses_mode_multiplier(self) -> None:
|
||||
self.assertEqual(OlympusEngine(bet="20", mode="normal").cost, Decimal("20"))
|
||||
self.assertEqual(OlympusEngine(bet="20", mode="ante").cost, Decimal("40"))
|
||||
self.assertEqual(OlympusEngine(bet="20", mode="buy_fs").cost, Decimal("1000"))
|
||||
|
||||
def test_invalid_mode_raises(self) -> None:
|
||||
with self.assertRaises(RuleViolation):
|
||||
OlympusEngine(bet="1", mode="buy_super_fs")
|
||||
|
||||
def test_bet_not_in_mode_tiers_raises(self) -> None:
|
||||
# normal 没有 7 档
|
||||
with self.assertRaises(RuleViolation):
|
||||
OlympusEngine(bet="7", mode="normal")
|
||||
# buy_fs 没有 40 档
|
||||
with self.assertRaises(RuleViolation):
|
||||
OlympusEngine(bet="40", mode="buy_fs")
|
||||
|
||||
|
||||
class TestMaxWinCap(unittest.TestCase):
|
||||
"""单局赢奖封顶 1000 倍基础下注。"""
|
||||
|
||||
def test_win_capped_at_1000x(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
engine.begin_spin()
|
||||
# CROWN 12个=50x,配 1000 倍炸弹 → 未封顶前 50000x
|
||||
engine.record_cascade(
|
||||
symbols={"CROWN": list(range(12))}, bombs=[Bomb("m1", 1000)]
|
||||
)
|
||||
spin = engine.end_spin()
|
||||
self.assertTrue(spin.win_capped)
|
||||
self.assertEqual(spin.total_win, Decimal("1000"))
|
||||
self.assertEqual(engine.state.total_win, Decimal("1000"))
|
||||
|
||||
def test_cap_applies_to_session_cumulative(self) -> None:
|
||||
engine = OlympusEngine(bet="1")
|
||||
# 第 1 次 FS 旋转拿 900x(基础 10x × 炸弹 90),累计器 → 90
|
||||
engine.begin_spin(is_free_spin=True)
|
||||
engine.record_cascade(symbols={"CROWN": list(range(8))}, bombs=[Bomb("m1", 50), Bomb("m2", 25), Bomb("m3", 15)])
|
||||
s1 = engine.end_spin()
|
||||
self.assertEqual(s1.total_win, Decimal("900"))
|
||||
self.assertFalse(s1.win_capped)
|
||||
# 第 2 次 FS 旋转再赢(无新炸弹,吃累计 90)→ 只能再拿 100x,整局顶格 1000x
|
||||
engine.begin_spin(is_free_spin=True)
|
||||
engine.record_cascade(symbols={"CROWN": list(range(8))}, bombs=[])
|
||||
s2 = engine.end_spin()
|
||||
self.assertTrue(s2.win_capped)
|
||||
self.assertEqual(engine.state.total_win, Decimal("1000"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,263 @@
|
||||
"""回合生成器规则校验(unittest)。不依赖第三方库,可直接 `python tests/test_simulator.py` 运行。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
import unittest
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
# 支持直接 `python tests/test_simulator.py` 运行(无需先安装为包)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from olympus.config import BIGWIN_LEVELS, BIGWIN_TIERS, BOMB_SHORT, SCATTER_SHORT
|
||||
import olympus_simulator as sim
|
||||
from olympus_simulator import generate_round
|
||||
|
||||
|
||||
def _cells(step):
|
||||
return [c for row in step['grid_state'] for c in row]
|
||||
|
||||
|
||||
class TestBuyFsTriggerSpin(unittest.TestCase):
|
||||
"""buy_fs 触发盘:首盘固定 4 个 SCATTER,且整盘不出炸弹(炸弹只在免费旋转内生效)。"""
|
||||
|
||||
def test_trigger_pages(self) -> None:
|
||||
for _ in range(100):
|
||||
_, steps, _ = generate_round({'box_id': 0, 'box_type': 'buy_fs'})
|
||||
trigger = [s for s in steps if s['free_spin_id'] == 0]
|
||||
self.assertTrue(trigger)
|
||||
for s in trigger:
|
||||
cells = _cells(s)
|
||||
self.assertEqual(cells.count(SCATTER_SHORT), 4)
|
||||
self.assertEqual(cells.count(BOMB_SHORT), 0)
|
||||
|
||||
|
||||
class TestBuyFsFreeSpinHits(unittest.TestCase):
|
||||
"""buy_fs 免费旋转"每旋保底消除":自然阶段命中率应远高于旧实现的 ~13%(旧版 45% 强制空转)。"""
|
||||
|
||||
def test_natural_phase_hit_rate(self) -> None:
|
||||
random.seed(12345)
|
||||
box = {'box_id': 0, 'box_type': 'buy_fs'}
|
||||
total = hit = 0
|
||||
for _ in range(60):
|
||||
_, steps, _ = generate_round(box, None)
|
||||
spins = Counter(s['free_spin_id'] for s in steps if s['free_spin_id'] > 0)
|
||||
total += len(spins)
|
||||
hit += sum(1 for v in spins.values() if v > 1)
|
||||
self.assertGreater(hit / total, 0.45)
|
||||
|
||||
|
||||
class TestBombOnlyInFreeSpins(unittest.TestCase):
|
||||
"""炸弹只存在于免费旋转内:
|
||||
· normal/ante 的基础旋(含进 FS 的触发盘、含补桶植入)一律不出炸弹;
|
||||
· 三个模式一旦进 FS 规则一致(炸弹 + 累计器),normal/ante 靠它演 100x/150x/200x 桶。"""
|
||||
|
||||
def test_normal_ante_base_spin_never_emit_bomb(self) -> None:
|
||||
random.seed(20260822)
|
||||
for mode in ('normal', 'ante'):
|
||||
box = {'box_id': 0, 'box_type': mode}
|
||||
bombs = 0
|
||||
for bias in (None, 'zero', 0.5, 10.0, 150.0):
|
||||
for _ in range(400):
|
||||
_, steps, _ = generate_round(box, bias)
|
||||
bombs += sum(_cells(s).count(BOMB_SHORT)
|
||||
for s in steps if s['free_spin_id'] == 0)
|
||||
self.assertEqual(bombs, 0, '%s 基础旋不该出炸弹' % mode)
|
||||
|
||||
def test_normal_ante_free_spins_have_bomb(self) -> None:
|
||||
random.seed(11)
|
||||
for mode in ('normal', 'ante'):
|
||||
box = {'box_id': 0, 'box_type': mode}
|
||||
grids = bombs = 0
|
||||
for _ in range(400):
|
||||
# 目标 150x ≥ FS_ONLY_TARGET_MUL → 该模式的 150x 桶只能靠自然触发 FS 的回合来装
|
||||
_, steps, _ = generate_round(box, 150.0)
|
||||
for s in steps:
|
||||
if s['free_spin_id'] > 0:
|
||||
grids += 1
|
||||
bombs += _cells(s).count(BOMB_SHORT)
|
||||
self.assertGreater(grids, 0, '%s 高倍桶应该是 FS 回合' % mode)
|
||||
self.assertGreater(bombs / (30.0 * grids), 0.02,
|
||||
'%s 的 FS 盘面应该有炸弹' % mode)
|
||||
|
||||
def test_high_target_round_enters_free_spins(self) -> None:
|
||||
"""目标高到基础旋赔不出来(≥80x)时,生成器必须改用自然触发 FS 的回合。"""
|
||||
for mode in ('normal', 'ante'):
|
||||
_, steps, _ = generate_round({'box_id': 0, 'box_type': mode}, 150.0)
|
||||
self.assertTrue(any(s['free_spin_id'] > 0 for s in steps))
|
||||
trigger = [s for s in steps if s['free_spin_id'] == 0][-1]
|
||||
self.assertGreaterEqual(_cells(trigger).count(SCATTER_SHORT), 4)
|
||||
self.assertEqual(_cells(trigger).count(BOMB_SHORT), 0)
|
||||
|
||||
def test_buy_fs_still_has_bomb_in_free_spins(self) -> None:
|
||||
random.seed(7)
|
||||
box = {'box_id': 0, 'box_type': 'buy_fs'}
|
||||
bombs = 0
|
||||
for _ in range(200):
|
||||
_, steps, _ = generate_round(box, None)
|
||||
bombs += sum(_cells(s).count(BOMB_SHORT) for s in steps if s['free_spin_id'] > 0)
|
||||
self.assertGreater(bombs, 0)
|
||||
|
||||
|
||||
class TestBigWinLevels(unittest.TestCase):
|
||||
"""爆奖动画只有小/中/大 3 档,而且只在免费旋转里判档:
|
||||
· 基础旋(含触发盘,`free_spin_id=0`)恒为 null —— 这两个模式的普通旋转不播大奖动画;
|
||||
· FS 旋的档位 = 该旋末帧 winAmount 与新阈值(两种情况两套)比对的结果。"""
|
||||
|
||||
def test_tiers_are_three_and_two_cases(self) -> None:
|
||||
self.assertEqual(BIGWIN_LEVELS, ('small', 'middle', 'big'))
|
||||
self.assertEqual(BIGWIN_TIERS['normal'], BIGWIN_TIERS['ante']) # 自然 FS 共用一套
|
||||
self.assertNotEqual(BIGWIN_TIERS['normal'], BIGWIN_TIERS['buy_fs'])
|
||||
for tiers in BIGWIN_TIERS.values():
|
||||
self.assertEqual(len(tiers), len(BIGWIN_LEVELS))
|
||||
|
||||
def test_only_free_spin_final_frames_get_level(self) -> None:
|
||||
random.seed(4242)
|
||||
cases = (('normal', None), ('ante', None), ('buy_fs', None),
|
||||
('normal', 150.0), ('ante', 200.0))
|
||||
for mode, bias in cases:
|
||||
box = {'box_id': 0, 'box_type': mode}
|
||||
for _ in range(120):
|
||||
_, steps, _ = generate_round(box, bias)
|
||||
for s in steps:
|
||||
if s['free_spin_id'] == 0 or not s['is_final']:
|
||||
self.assertIsNone(s['bigwin_level'],
|
||||
'%s 基础旋/非末帧不该有爆奖档' % mode)
|
||||
continue
|
||||
want = None
|
||||
for name, threshold in zip(BIGWIN_LEVELS, BIGWIN_TIERS[mode]):
|
||||
if float(s['win_amount']) >= threshold:
|
||||
want = name
|
||||
self.assertEqual(s['bigwin_level'], want)
|
||||
|
||||
|
||||
class TestFreeSpinFloorSpread(unittest.TestCase):
|
||||
"""保底消除"随机间隔":既不能出现长空尾,也不能变成固定节奏。
|
||||
|
||||
口径:dry = 该旋所有 cascade 步 `base_score` 全 0(scatter 赔付不算消除,S 不参与消除)。
|
||||
两版历史(都别退回去):
|
||||
· 最初按"剩余预算÷剩余旋数"算概率、扣预算扣的是被累计倍数放大后的赢额 → 买 FS 87.9% 的局
|
||||
≥4 旋连空、51.7% 的局尾巴一直空转、单局最长 29 旋;
|
||||
· 修成"等距铺"后虽然没长空尾,但 87% 的局"最长连空正好 4 旋",玩家几局就看穿"到第 4 旋肯定消"。"""
|
||||
|
||||
@staticmethod
|
||||
def _max_dry(steps) -> int:
|
||||
per = {}
|
||||
for s in steps:
|
||||
if s['free_spin_id'] > 0:
|
||||
per[s['free_spin_id']] = per.get(s['free_spin_id'], 0.0) + float(s['base_score'])
|
||||
run = mx = 0
|
||||
for k in sorted(per):
|
||||
run = 0 if per[k] > 0 else run + 1
|
||||
mx = max(mx, run)
|
||||
return mx
|
||||
|
||||
def test_dry_run_stays_short(self) -> None:
|
||||
random.seed(20260916)
|
||||
for bias in (25.0, None):
|
||||
runs = [self._max_dry(generate_round({'box_id': 0, 'box_type': 'buy_fs'}, bias)[1])
|
||||
for _ in range(80)]
|
||||
avg, worst = sum(runs) / len(runs), max(runs)
|
||||
self.assertLess(avg, 7, 'bias=%s 平均最长连空 %.1f 太长(旧实现 9.6)' % (bias, avg))
|
||||
self.assertLessEqual(worst, 9, 'bias=%s 出现 %d 旋死尾巴(旧实现最长 29)' % (bias, worst))
|
||||
|
||||
def test_gap_is_not_a_fixed_cadence(self) -> None:
|
||||
"""最长连空的取值必须散开:等距版 87% 的局都卡在同一个值。"""
|
||||
random.seed(4242)
|
||||
runs = [self._max_dry(generate_round({'box_id': 0, 'box_type': 'buy_fs'}, 25.0)[1])
|
||||
for _ in range(120)]
|
||||
top, cnt = Counter(runs).most_common(1)[0]
|
||||
self.assertGreaterEqual(len(set(runs)), 3, '最长连空只有 %s,节奏太固定' % sorted(set(runs)))
|
||||
self.assertLess(cnt / len(runs), 0.7, '最长连空过于集中:%.0f%% 都是 %d 旋' % (100 * cnt / len(runs), top))
|
||||
|
||||
|
||||
class TestTumbleGeometry(unittest.TestCase):
|
||||
"""每一步都要能按"消掉上一步的连线 → 重力下落 → 补位"复刻出 `grid_state`:
|
||||
前端据此播动画,任何一格对不上/漏掉都会表现成画错符号或空白格子。"""
|
||||
|
||||
def _check_round(self, steps) -> None:
|
||||
rows, cols = 5, 6
|
||||
for i in range(1, len(steps)):
|
||||
prev, cur = steps[i - 1], steps[i]
|
||||
drops = json.loads(cur['drop_data'] or '[]')
|
||||
fills = json.loads(cur['fill_data'] or '[]')
|
||||
if prev['free_spin_id'] != cur['free_spin_id']:
|
||||
# 新的一旋:整盘重发,不该带下落/补位
|
||||
self.assertEqual(drops, [])
|
||||
self.assertEqual(fills, [])
|
||||
continue
|
||||
links = json.loads(prev['symbol_links'] or '[]')
|
||||
removed = {p - 1 for l in links if l.get('tumble') == prev['cascade_seq']
|
||||
for p in l['loc']}
|
||||
old = prev['grid_state']
|
||||
board = [['' if (r * cols + c) in removed else old[r][c] for c in range(cols)]
|
||||
for r in range(rows)]
|
||||
nb = [[''] * cols for _ in range(rows)]
|
||||
exp_drops = []
|
||||
for c in range(cols):
|
||||
surv = [(r, board[r][c]) for r in range(rows - 1, -1, -1) if board[r][c] != '']
|
||||
for k, (r, sym) in enumerate(surv):
|
||||
nr = rows - 1 - k
|
||||
nb[nr][c] = sym
|
||||
if nr != r:
|
||||
exp_drops.append({'from': r * cols + c + 1, 'to': nr * cols + c + 1,
|
||||
'symbol': sym})
|
||||
key = lambda d: (d['from'], d['to'])
|
||||
self.assertEqual(sorted(exp_drops, key=key), sorted(drops, key=key))
|
||||
# 下落目标的 symbol 必须是"这一波结束后"的符号(= grid_state 同位置)
|
||||
for d in drops:
|
||||
p = d['to'] - 1
|
||||
self.assertEqual(d['symbol'], cur['grid_state'][p // cols][p % cols])
|
||||
# 补位必须正好盖住所有空位,且符号与最终盘面一致
|
||||
empty = sorted(r * cols + c for c in range(cols) for r in range(rows)
|
||||
if nb[r][c] == '')
|
||||
self.assertEqual(sorted(f['pos'] - 1 for f in fills), empty)
|
||||
for f in fills:
|
||||
p = f['pos'] - 1
|
||||
self.assertEqual(f['symbol'], cur['grid_state'][p // cols][p % cols])
|
||||
nb[p // cols][p % cols] = f['symbol']
|
||||
self.assertEqual(nb, cur['grid_state'], '复刻盘面 ≠ grid_state')
|
||||
|
||||
def test_all_modes(self) -> None:
|
||||
random.seed(31337)
|
||||
for mode, biases in (('normal', (None, 'zero', 20.0, 150.0)),
|
||||
('ante', (None, 100.0, 200.0)),
|
||||
('buy_fs', (None, 25.0, 500.0))):
|
||||
for bias in biases:
|
||||
for _ in range(60):
|
||||
_, steps, _ = generate_round({'box_id': 0, 'box_type': mode}, bias)
|
||||
self._check_round(steps)
|
||||
|
||||
|
||||
class TestFreeSpinCap(unittest.TestCase):
|
||||
"""30 旋上限(15 + 5×3):到上限之后不得再落 3+ 个 S。
|
||||
|
||||
旧实现只在"加旋"那行有闸门,写 retrigger_info 的那行没有 —— 到 30 旋后照样摇出 3+ 个 S、
|
||||
照样写 extraSpins=5,玩家白看 "+5" 提示(全库 650 个这种帧)。这里用"每旋必落 5 个 S"的
|
||||
极端分布把上限逼出来:应恰好 30 旋、恰好 3 次重触发,上限之后的旋 S 数 ≤2。
|
||||
"""
|
||||
|
||||
def test_no_retrigger_beyond_cap(self) -> None:
|
||||
random.seed(20260919)
|
||||
original = sim.FREESPIN_SCATTER
|
||||
sim.FREESPIN_SCATTER = {'default': {5: 1.0}, 'buy_fs': {5: 1.0}}
|
||||
try:
|
||||
for mode in ('normal', 'ante', 'buy_fs'):
|
||||
steps, _ = sim._free_spin_session(mode)
|
||||
spins = {s['free_spin_id'] for s in steps}
|
||||
retrigger = [s for s in steps if s.get('retrigger_info')]
|
||||
self.assertEqual(len(spins), 30, f'{mode}: 旋数应为 30,实际 {len(spins)}')
|
||||
self.assertEqual(len(retrigger), 3, f'{mode}: 重触发应为 3 次,实际 {len(retrigger)}')
|
||||
for s in steps:
|
||||
if s['is_final'] and s['free_spin_id'] > 3:
|
||||
self.assertLessEqual(_cells(s).count(SCATTER_SHORT), 2,
|
||||
f'{mode}: 上限之后(第 {s["free_spin_id"]} 旋)又落了 3+ 个 S')
|
||||
finally:
|
||||
sim.FREESPIN_SCATTER = original
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# ========== 配置项 ==========
|
||||
JAVA_HOME="/project/jdk-21.0.10"
|
||||
MAVEN_PATH="/project/apache-maven-3.6.3/bin/mvn"
|
||||
# ===========================
|
||||
|
||||
export JAVA_HOME="$JAVA_HOME"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
|
||||
echo "--- 环境检查 ---"
|
||||
java -version 2>&1 | head -1
|
||||
$MAVEN_PATH -version 2>&1 | head -1
|
||||
echo "----------------"
|
||||
|
||||
git pull && $MAVEN_PATH clean && $MAVEN_PATH package -Dmaven.test.skip=true
|
||||
cp ./ruoyi-admin/target/ruoyi-admin.jar ./
|
||||
@@ -0,0 +1,336 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi</artifactId>
|
||||
<version>4.8.2</version>
|
||||
|
||||
<name>ruoyi</name>
|
||||
<url>http://www.ruoyi.vip</url>
|
||||
<description>若依管理系统</description>
|
||||
|
||||
<properties>
|
||||
<ruoyi.version>4.8.2</ruoyi.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<java.version>21</java.version>
|
||||
<maven-jar-plugin.version>3.1.1</maven-jar-plugin.version>
|
||||
<spring-boot.version>4.0.3</spring-boot.version>
|
||||
<shiro.version>2.1.0</shiro.version>
|
||||
<mybatis-spring-boot.version>4.0.1</mybatis-spring-boot.version>
|
||||
<thymeleaf.extras.shiro.version>2.1.0</thymeleaf.extras.shiro.version>
|
||||
<druid.version>1.2.28</druid.version>
|
||||
<yauaa.version>8.1.0</yauaa.version>
|
||||
<bitwalker.version>1.21</bitwalker.version>
|
||||
<swagger.version>3.0.0</swagger.version>
|
||||
<kaptcha.version>2.3.3</kaptcha.version>
|
||||
<tianai-captcha.version>1.5.5</tianai-captcha.version>
|
||||
<pagehelper.boot.version>2.1.1</pagehelper.boot.version>
|
||||
<fastjson.version>2.0.53</fastjson.version>
|
||||
<oshi.version>6.10.0</oshi.version>
|
||||
<commons.io.version>2.21.0</commons.io.version>
|
||||
<commons.collections.version>3.2.2</commons.collections.version>
|
||||
<poi.version>4.1.2</poi.version>
|
||||
<velocity.version>2.3</velocity.version>
|
||||
<jwt.version>0.9.1</jwt.version>
|
||||
<springdoc.version>3.0.2</springdoc.version>
|
||||
</properties>
|
||||
|
||||
<!-- 依赖声明 -->
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-bom</artifactId>
|
||||
<version>1.16.3</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- SpringBoot的依赖配置 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- 阿里数据库连接池 -->
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>druid-spring-boot-4-starter</artifactId>
|
||||
<version>${druid.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 验证码 -->
|
||||
<dependency>
|
||||
<groupId>pro.fessional</groupId>
|
||||
<artifactId>kaptcha</artifactId>
|
||||
<version>${kaptcha.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Shiro核心框架 -->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.apache.shiro</groupId>-->
|
||||
<!-- <artifactId>shiro-core</artifactId>-->
|
||||
<!-- <classifier>jakarta</classifier>-->
|
||||
<!-- <version>${shiro.version}</version>-->
|
||||
<!-- </dependency>-->
|
||||
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.apache.shiro</groupId>-->
|
||||
<!-- <artifactId>shiro-web</artifactId>-->
|
||||
<!-- <classifier>jakarta</classifier>-->
|
||||
<!-- <version>${shiro.version}</version>-->
|
||||
<!-- </dependency>-->
|
||||
|
||||
<!-- Shiro使用Spring框架 -->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.apache.shiro</groupId>-->
|
||||
<!-- <artifactId>shiro-spring</artifactId>-->
|
||||
<!-- <classifier>jakarta</classifier>-->
|
||||
<!-- <version>${shiro.version}</version>-->
|
||||
<!-- <!– 排除仍使用了javax.servlet的依赖 –>-->
|
||||
<!-- <exclusions>-->
|
||||
<!-- <exclusion>-->
|
||||
<!-- <groupId>org.apache.shiro</groupId>-->
|
||||
<!-- <artifactId>shiro-web</artifactId>-->
|
||||
<!-- </exclusion>-->
|
||||
<!-- </exclusions>-->
|
||||
<!-- </dependency>-->
|
||||
|
||||
<!-- Shiro使用EhCache缓存框架 -->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.apache.shiro</groupId>-->
|
||||
<!-- <artifactId>shiro-ehcache</artifactId>-->
|
||||
<!-- <version>${shiro.version}</version>-->
|
||||
<!-- </dependency>-->
|
||||
|
||||
<!-- thymeleaf模板引擎和shiro框架的整合 -->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>com.github.theborakompanioni</groupId>-->
|
||||
<!-- <artifactId>thymeleaf-extras-shiro</artifactId>-->
|
||||
<!-- <version>${thymeleaf.extras.shiro.version}</version>-->
|
||||
<!-- </dependency>-->
|
||||
|
||||
<!-- mybatis依赖 -->
|
||||
<dependency>
|
||||
<groupId>org.mybatis.spring.boot</groupId>
|
||||
<artifactId>mybatis-spring-boot-starter</artifactId>
|
||||
<version>${mybatis-spring-boot.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 解析客户端操作系统、浏览器等 -->
|
||||
<dependency>
|
||||
<groupId>nl.basjes.parse.useragent</groupId>
|
||||
<artifactId>yauaa</artifactId>
|
||||
<version>${yauaa.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>eu.bitwalker</groupId>
|
||||
<artifactId>UserAgentUtils</artifactId>
|
||||
<version>${bitwalker.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- pagehelper 分页插件 -->
|
||||
<dependency>
|
||||
<groupId>com.github.pagehelper</groupId>
|
||||
<artifactId>pagehelper-spring-boot-starter</artifactId>
|
||||
<version>${pagehelper.boot.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 获取系统信息 -->
|
||||
<dependency>
|
||||
<groupId>com.github.oshi</groupId>
|
||||
<artifactId>oshi-core</artifactId>
|
||||
<version>${oshi.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- spring-doc -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>${springdoc.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Swagger3依赖 -->
|
||||
<dependency>
|
||||
<groupId>io.springfox</groupId>
|
||||
<artifactId>springfox-boot-starter</artifactId>
|
||||
<version>${swagger.version}</version>
|
||||
<!-- <exclusions>-->
|
||||
<!-- <exclusion>-->
|
||||
<!-- <groupId>io.swagger</groupId>-->
|
||||
<!-- <artifactId>swagger-models</artifactId>-->
|
||||
<!-- </exclusion>-->
|
||||
<!-- </exclusions>-->
|
||||
</dependency>
|
||||
|
||||
<!-- io常用工具类 -->
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
<version>${commons.io.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- excel工具 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>${poi.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- velocity代码生成使用模板 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.velocity</groupId>
|
||||
<artifactId>velocity-engine-core</artifactId>
|
||||
<version>${velocity.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- collections工具类 -->
|
||||
<dependency>
|
||||
<groupId>commons-collections</groupId>
|
||||
<artifactId>commons-collections</artifactId>
|
||||
<version>${commons.collections.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 阿里JSON解析器 -->
|
||||
<dependency>
|
||||
<groupId>com.alibaba.fastjson2</groupId>
|
||||
<artifactId>fastjson2</artifactId>
|
||||
<version>${fastjson.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Token生成与解析-->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt</artifactId>
|
||||
<version>${jwt.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 验证码 -->
|
||||
<dependency>
|
||||
<groupId>pro.fessional</groupId>
|
||||
<artifactId>kaptcha</artifactId>
|
||||
<version>${kaptcha.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 滑块行为验证码 -->
|
||||
<dependency>
|
||||
<groupId>cloud.tianai.captcha</groupId>
|
||||
<artifactId>tianai-captcha-springboot-starter</artifactId>
|
||||
<version>${tianai-captcha.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 定时任务-->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-quartz</artifactId>
|
||||
<version>${ruoyi.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 代码生成-->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-generator</artifactId>
|
||||
<version>${ruoyi.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 核心模块-->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-framework</artifactId>
|
||||
<version>${ruoyi.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 系统模块-->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-system</artifactId>
|
||||
<version>${ruoyi.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 通用工具-->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-common</artifactId>
|
||||
<version>${ruoyi.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>skins-service</artifactId>
|
||||
<version>4.8.2</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<modules>
|
||||
<module>ruoyi-admin</module>
|
||||
<module>ruoyi-framework</module>
|
||||
<module>ruoyi-system</module>
|
||||
<module>ruoyi-quartz</module>
|
||||
<module>ruoyi-generator</module>
|
||||
<module>ruoyi-common</module>
|
||||
<module>skins-model</module>
|
||||
<module>skins-service</module>
|
||||
<module>skins-promo</module>
|
||||
</modules>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
|
||||
<dependencies>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
<parameters>true</parameters>
|
||||
<source>${java.version}</source>
|
||||
<target>${java.version}</target>
|
||||
<encoding>${project.build.sourceEncoding}</encoding>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>public</id>
|
||||
<name>aliyun nexus</name>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<releases>
|
||||
<enabled>true</enabled>
|
||||
</releases>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<pluginRepositories>
|
||||
<pluginRepository>
|
||||
<id>public</id>
|
||||
<name>aliyun nexus</name>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<releases>
|
||||
<enabled>true</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</pluginRepository>
|
||||
</pluginRepositories>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,5 @@
|
||||
Android 4.1
|
||||
IOS 7.1
|
||||
Chrome > 31
|
||||
ff > 31
|
||||
ie >= 11
|
||||
@@ -0,0 +1,28 @@
|
||||
# 环境
|
||||
VITE_NODE_ENV=development
|
||||
|
||||
# 接口前缀
|
||||
VITE_API_BASE_PATH=http://localhost:28081
|
||||
|
||||
# 打包路径
|
||||
VITE_BASE_PATH=/
|
||||
|
||||
# 标题
|
||||
VITE_APP_TITLE=推广后台
|
||||
|
||||
# 是否全量引入element-plus样式
|
||||
VITE_USE_ALL_ELEMENT_PLUS_STYLE=true
|
||||
|
||||
# 是否开启mock
|
||||
VITE_USE_MOCK=true
|
||||
|
||||
# 是否使用在线图标
|
||||
VITE_USE_ONLINE_ICON=true
|
||||
|
||||
# 是否隐藏全局设置按钮
|
||||
VITE_HIDE_GLOBAL_SETTING=true
|
||||
|
||||
# ==================== AES 加密开关 ====================
|
||||
# 注意:pnpm run dev 使用 --mode base,实际加载的是本文件!
|
||||
# false=关闭(方便调试),true=开启(需与后端 aes.enabled 保持一致)
|
||||
VITE_USE_AES_ENCRYPT=false
|
||||
@@ -0,0 +1,45 @@
|
||||
# 环境
|
||||
VITE_NODE_ENV=production
|
||||
|
||||
# 接口前缀
|
||||
VITE_API_BASE_PATH=
|
||||
|
||||
# 打包路径
|
||||
VITE_BASE_PATH=/dist-dev/
|
||||
|
||||
# 是否删除debugger
|
||||
VITE_DROP_DEBUGGER=false
|
||||
|
||||
# 是否删除console.log
|
||||
VITE_DROP_CONSOLE=false
|
||||
|
||||
# 是否sourcemap
|
||||
VITE_SOURCEMAP=true
|
||||
|
||||
# 输出路径
|
||||
VITE_OUT_DIR=dist-dev
|
||||
|
||||
# 标题
|
||||
VITE_APP_TITLE=ElementAdmin
|
||||
|
||||
# 是否包分析
|
||||
VITE_USE_BUNDLE_ANALYZER=false
|
||||
|
||||
# 是否全量引入element-plus样式
|
||||
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
|
||||
|
||||
# 是否开启mock
|
||||
VITE_USE_MOCK=true
|
||||
|
||||
# 是否切割css
|
||||
VITE_USE_CSS_SPLIT=true
|
||||
|
||||
# 是否使用在线图标
|
||||
VITE_USE_ONLINE_ICON=true
|
||||
|
||||
# 是否隐藏全局设置按钮
|
||||
VITE_HIDE_GLOBAL_SETTING=true
|
||||
|
||||
# ==================== AES 加密开关 ====================
|
||||
# false=关闭(方便开发调试),true=开启(生产环境必须)
|
||||
VITE_USE_AES_ENCRYPT=false
|
||||
@@ -0,0 +1,41 @@
|
||||
# 环境
|
||||
VITE_NODE_ENV=production
|
||||
|
||||
# 接口前缀
|
||||
VITE_API_BASE_PATH=
|
||||
|
||||
# 打包路径
|
||||
VITE_BASE_PATH=/vue-element-plus-admin/
|
||||
|
||||
# 是否删除debugger
|
||||
VITE_DROP_DEBUGGER=true
|
||||
|
||||
# 是否删除console.log
|
||||
VITE_DROP_CONSOLE=true
|
||||
|
||||
# 是否sourcemap
|
||||
VITE_SOURCEMAP=false
|
||||
|
||||
# 输出路径
|
||||
VITE_OUT_DIR=dist-pro
|
||||
|
||||
# 标题
|
||||
VITE_APP_TITLE=ElementAdmin
|
||||
|
||||
# 是否包分析
|
||||
VITE_USE_BUNDLE_ANALYZER=false
|
||||
|
||||
# 是否全量引入element-plus样式
|
||||
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
|
||||
|
||||
# 是否开启mock
|
||||
VITE_USE_MOCK=true
|
||||
|
||||
# 是否切割css
|
||||
VITE_USE_CSS_SPLIT=true
|
||||
|
||||
# 是否使用在线图标
|
||||
VITE_USE_ONLINE_ICON=true
|
||||
|
||||
# 是否隐藏全局设置按钮
|
||||
VITE_HIDE_GLOBAL_SETTING=false
|
||||
@@ -0,0 +1,45 @@
|
||||
# 环境
|
||||
VITE_NODE_ENV=production
|
||||
|
||||
# 接口前缀
|
||||
VITE_API_BASE_PATH=http://103.108.67.60:28081
|
||||
|
||||
# 打包路径
|
||||
VITE_BASE_PATH=/
|
||||
|
||||
# 是否删除debugger
|
||||
VITE_DROP_DEBUGGER=true
|
||||
|
||||
# 是否删除console.log
|
||||
VITE_DROP_CONSOLE=true
|
||||
|
||||
# 是否sourcemap
|
||||
VITE_SOURCEMAP=false
|
||||
|
||||
# 输出路径
|
||||
VITE_OUT_DIR=dist-pro
|
||||
|
||||
# 标题
|
||||
VITE_APP_TITLE=推广后台
|
||||
|
||||
# 是否包分析
|
||||
VITE_USE_BUNDLE_ANALYZER=true
|
||||
|
||||
# 是否全量引入element-plus样式
|
||||
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
|
||||
|
||||
# 是否开启mock
|
||||
VITE_USE_MOCK=false
|
||||
|
||||
# 是否切割css
|
||||
VITE_USE_CSS_SPLIT=true
|
||||
|
||||
# 是否使用在线图标
|
||||
VITE_USE_ONLINE_ICON=true
|
||||
|
||||
# 是否隐藏全局设置按钮
|
||||
VITE_HIDE_GLOBAL_SETTING=true
|
||||
|
||||
# ==================== AES 加密开关 ====================
|
||||
# 注意:必须与后端 application-pro.yml 的 aes.enabled 保持一致!
|
||||
VITE_USE_AES_ENCRYPT=false
|
||||
@@ -0,0 +1,45 @@
|
||||
# 环境
|
||||
VITE_NODE_ENV=production
|
||||
|
||||
# 接口前缀
|
||||
VITE_API_BASE_PATH=http://8.160.181.6:8081
|
||||
|
||||
# 打包路径
|
||||
VITE_BASE_PATH=/
|
||||
|
||||
# 是否删除debugger
|
||||
VITE_DROP_DEBUGGER=true
|
||||
|
||||
# 是否删除console.log
|
||||
VITE_DROP_CONSOLE=true
|
||||
|
||||
# 是否sourcemap
|
||||
VITE_SOURCEMAP=false
|
||||
|
||||
# 输出路径
|
||||
VITE_OUT_DIR=dist-pro
|
||||
|
||||
# 标题
|
||||
VITE_APP_TITLE=推广后台
|
||||
|
||||
# 是否包分析
|
||||
VITE_USE_BUNDLE_ANALYZER=true
|
||||
|
||||
# 是否全量引入element-plus样式
|
||||
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
|
||||
|
||||
# 是否开启mock
|
||||
VITE_USE_MOCK=false
|
||||
|
||||
# 是否切割css
|
||||
VITE_USE_CSS_SPLIT=true
|
||||
|
||||
# 是否使用在线图标
|
||||
VITE_USE_ONLINE_ICON=false
|
||||
|
||||
# 是否隐藏全局设置按钮
|
||||
VITE_HIDE_GLOBAL_SETTING=true
|
||||
|
||||
# ==================== AES 加密开关 ====================
|
||||
# 测试环境建议关闭,方便排查问题
|
||||
VITE_USE_AES_ENCRYPT=false
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
name: Automerge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- labeled
|
||||
- unlabeled
|
||||
- synchronize
|
||||
- opened
|
||||
- edited
|
||||
- ready_for_review
|
||||
- reopened
|
||||
- unlocked
|
||||
pull_request_review:
|
||||
types:
|
||||
- submitted
|
||||
status: {}
|
||||
|
||||
jobs:
|
||||
# 合并发布版本的 pr 到 master
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Automerge
|
||||
uses: 'pascalgn/automerge-action@v0.14.3'
|
||||
env:
|
||||
BASE_BRANCHES: 'release'
|
||||
GITHUB_TOKEN: '${{ secrets.TOKEN }}'
|
||||
MERGE_LABELS: ''
|
||||
MERGE_FILTER_AUTHOR: 'kailong321200875'
|
||||
|
||||
push-to-gh-pages:
|
||||
needs: [auto-merge]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: use Node.js 18
|
||||
uses: actions/setup-node@v2.1.2
|
||||
with:
|
||||
node-version: '18.x'
|
||||
|
||||
- name: Set SSH Environment
|
||||
env:
|
||||
DOCS_DEPLOY_KEY: ${{ secrets.ACTIONS_DEPLOY_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "$ACTIONS_DEPLOY_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan github.com > ~/.ssh/known_hosts
|
||||
chmod 700 ~/.ssh && chmod 600 ~/.ssh/*
|
||||
git config --local user.email "321200875@qq.com"
|
||||
git config --local user.name "kailong321200875"
|
||||
|
||||
# 发布到 github
|
||||
- name: Build Github
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm run build:pro
|
||||
|
||||
- name: Deploy Github
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
deploy_key: ${{secrets.ACTIONS_DEPLOY_KEY}}
|
||||
publish_branch: gh-pages
|
||||
publish_dir: ./dist-pro
|
||||
force_orphan: true
|
||||
cname: element-plus-admin.cn
|
||||
|
||||
push-to-gh-pages-gitee:
|
||||
needs: [auto-merge]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: use Node.js 18
|
||||
uses: actions/setup-node@v2.1.2
|
||||
with:
|
||||
node-version: '18.x'
|
||||
|
||||
- name: Set SSH Environment
|
||||
env:
|
||||
DOCS_DEPLOY_KEY: ${{ secrets.ACTIONS_DEPLOY_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "$ACTIONS_DEPLOY_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan github.com > ~/.ssh/known_hosts
|
||||
chmod 700 ~/.ssh && chmod 600 ~/.ssh/*
|
||||
git config --local user.email "321200875@qq.com"
|
||||
git config --local user.name "kailong321200875"
|
||||
|
||||
- name: Build Gitee
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm run build:gitee
|
||||
|
||||
# 发布到 gitee
|
||||
- name: Deploy Gitee
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
deploy_key: ${{secrets.ACTIONS_DEPLOY_KEY}}
|
||||
publish_branch: gh-pages-gitee
|
||||
publish_dir: ./dist-pro
|
||||
force_orphan: true
|
||||
|
||||
- name: Sync Github Repos To Gitee # 名字随便起
|
||||
uses: Yikun/hub-mirror-action@v1.1 # 使用Yikun/hub-mirror-action
|
||||
with:
|
||||
src: github/kailong321200875 # 源端账户名(github)
|
||||
dst: gitee/kailong110120130 # 目的端账户名(gitee)
|
||||
dst_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} # SSH密钥对中的私钥
|
||||
dst_token: ${{ secrets.GITEE_TOKEN }} # Gitee账户的私人令牌
|
||||
account_type: user # 账户类型
|
||||
clone_style: 'https' # 使用https方式进行clone,也可以使用ssh
|
||||
debug: true # 启用后会显示所有执行命令
|
||||
force_update: true # 启用后,强制同步,即强制覆盖目的端仓库
|
||||
static_list: 'vue-element-plus-admin' # 静态同步列表,在此填写需要同步的仓库名称,可填写多个
|
||||
timeout: '600s' # git超时设置,超时后会自动重试git操作
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
|
||||
name: Release
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: GoogleCloudPlatform/release-please-action@v3
|
||||
id: release
|
||||
with:
|
||||
token: ${{ secrets.TOKEN }}
|
||||
default-branch: release
|
||||
release-type: node
|
||||
package-name: standard-version
|
||||
changelog-types: '[{"type": "types", "section":"Types", "hidden": false},{"type": "revert", "section":"Reverts", "hidden": false},{"type": "feat", "section": "Features", "hidden": false},{"type": "fix", "section": "Bug Fixes", "hidden": false},{"type": "improvement", "section": "Feature Improvements", "hidden": false},{"type": "docs", "section":"Docs", "hidden": false},{"type": "style", "section":"Styling", "hidden": false},{"type": "refactor", "section":"Code Refactoring", "hidden": false},{"type": "perf", "section":"Performance Improvements", "hidden": false},{"type": "test", "section":"Tests", "hidden": false},{"type": "build", "section":"Build System", "hidden": false},{"type": "ci", "section":"CI", "hidden":false}]'
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
/dist*
|
||||
pnpm-debug
|
||||
stats.html
|
||||
.idea
|
||||
@@ -0,0 +1 @@
|
||||
npx --no -- commitlint --edit $1
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
'*.{js,jsx,ts,tsx}': ['eslint --fix', 'prettier --write'],
|
||||
'{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': ['prettier --parser json --write'],
|
||||
'package.json': ['prettier --write'],
|
||||
'*.vue': ['prettier --write', 'stylelint --fix'],
|
||||
'*.{scss,less,styl,css,html}': ['stylelint --fix', 'prettier --write'],
|
||||
'*.md': ['prettier --write'],
|
||||
'*.hbs': ['prettier --write']
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
npm run ts:check
|
||||
npm run lint:lint-staged
|
||||
@@ -0,0 +1,9 @@
|
||||
/node_modules/**
|
||||
/dist/
|
||||
/dist*
|
||||
/public/*
|
||||
/docs/*
|
||||
/src/types/env.d.ts
|
||||
/docs/**/*
|
||||
/plop/**/*
|
||||
CHANGELOG
|
||||
@@ -0,0 +1,6 @@
|
||||
/dist/*
|
||||
/public/*
|
||||
public/*
|
||||
/dist*
|
||||
/src/types/env.d.ts
|
||||
/docs/**/*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
FROM node:18.0.0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g pnpm@8.1.0
|
||||
|
||||
COPY package.json .
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD [ "pnpm", "run", "dev" ]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021-present Archer
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,148 @@
|
||||
<div align="center"> <a href="https://github.com/kailong321200875/vue-element-plus-admin"> <img width="100" src="./public/logo.png"> </a> <br> <br>
|
||||
|
||||
[](LICENSE) [](repo-size) [](last-commit) [](stars) [](forks) [](release) [](watchers)
|
||||
|
||||
<h1>vue-element-plus-admin</h1>
|
||||
</div>
|
||||
|
||||
**English** | [中文](./README.zh-CN.md)
|
||||
|
||||
## Introduction
|
||||
|
||||
vue-element-plus-admin is a free and open source middle and background template based on `element-plus`. Developed using the latest mainstream technologies such as `vue3`, `vite` and `typescript`, the out of the box middle and background front-end solution can be used as the starting template of the project and learning reference. And always pay attention to the latest technological trends and update them as soon as possible.
|
||||
|
||||
vue-element-plus-admin is positioned as a background integration scheme, which is not suitable for secondary development as a basic template. Because it integrates many functions that you may not use, it will cause a lot of code redundancy. If your project doesn't pay attention to this problem, you can also directly carry out secondary development based on it.
|
||||
|
||||
If you need a basic template, please switch to the `mini` branch. `mini` simply integrates some common layout functions such as layout and dynamic menu, which is more suitable for developers to carry out secondary development.
|
||||
|
||||
## Feature
|
||||
|
||||
- **State of The Art Development**:Use front-end front-end technology development such as Vue3/vite4
|
||||
- **TypeScript**: Application-level JavaScript language
|
||||
- **Theming**: Configurable themes
|
||||
- **International**:Built-in complete internationalization program
|
||||
- **Mock Server** Built-in mock data scheme
|
||||
- **Authority** Built-in complete dynamic routing permission generation scheme.
|
||||
- **Component** Multiple commonly used components are encapsulated twice
|
||||
- **Examples** Built-in rich examples
|
||||
|
||||
## Preview
|
||||
|
||||
- [vue-element-plus-admin](https://element-plus-admin.cn/) - Full version of the github site
|
||||
- [vue-element-plus-admin](https://kailong110120130.gitee.io/vue-element-plus-admin) - Full version of the gitee site
|
||||
|
||||
account: **admin/admin**
|
||||
|
||||
Online examples do not apply to menu filtering by default, but directly use Static routing
|
||||
|
||||
## Documentation
|
||||
|
||||
[Document Github](https://element-plus-admin-doc.cn/)
|
||||
|
||||
[Document Gitee](https://kailong110120130.gitee.io/vue-element-plus-admin-doc)
|
||||
|
||||
## Preparation
|
||||
|
||||
- [node](http://nodejs.org/) and [git](https://git-scm.com/) - Project development environment
|
||||
- [Vite](https://vitejs.dev/) - Familiar with vite features
|
||||
- [Vue3](https://v3.vuejs.org/) - Familiar with Vue basic syntax
|
||||
- [TypeScript](https://www.typescriptlang.org/) - Familiar with the basic syntax of `TypeScript`
|
||||
- [Es6+](http://es6.ruanyifeng.com/) - Familiar with es6 basic syntax
|
||||
- [Vue-Router-Next](https://next.router.vuejs.org/) - Familiar with the basic use of vue-router
|
||||
- [Element-Plus](https://element-plus.org/) - Familiar with the basic use of element-plus
|
||||
- [Mock.js](https://github.com/nuysoft/Mock) - mockjs basic syntax
|
||||
|
||||
## Install and use
|
||||
|
||||
- Get the project code
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kailong321200875/vue-element-plus-admin.git
|
||||
```
|
||||
|
||||
- Installation dependencies
|
||||
|
||||
```bash
|
||||
cd vue-element-plus-admin
|
||||
|
||||
pnpm install
|
||||
|
||||
```
|
||||
|
||||
- run
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
- build
|
||||
|
||||
```bash
|
||||
pnpm run build:pro
|
||||
```
|
||||
|
||||
## Change Log
|
||||
|
||||
[CHANGELOG](./CHANGELOG.md)
|
||||
|
||||
## How to contribute
|
||||
|
||||
<a href="https://github.com/kailong321200875/vue-element-plus-admin/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kailong321200875/vue-element-plus-admin" />
|
||||
</a>
|
||||
|
||||
You can [Raise an issue](https://github.com/kailong321200875/vue-element-plus-admin/issues/new) Or submit a Pull Request.
|
||||
|
||||
**Pull Request:**
|
||||
|
||||
1. Fork code
|
||||
2. Create your own branch: `git checkout -b feat/xxxx`
|
||||
3. Submit your changes: `git commit -am 'feat(function): add xxxxx'`
|
||||
4. Push your branch: `git push origin feat/xxxx`
|
||||
5. submit `pull request`
|
||||
|
||||
## Git Contribution submission specification
|
||||
|
||||
- `feat` New features
|
||||
- `fix` Fix bugs
|
||||
- `docs` document
|
||||
- `style` Format and style (changes that do not affect code operation)
|
||||
- `refactor` Refactor
|
||||
- `perf` Optimize related, such as improving performance and experience
|
||||
- `test` Add test
|
||||
- `build` Compilation related modifications, changes to project construction or dependencies
|
||||
- `ci` Continuous integration modification
|
||||
- `chore` Changes in the construction process or auxiliary tools
|
||||
- `revert` Rollback to previous version
|
||||
- `workflow` Workflow improvement
|
||||
- `mod` Uncertain modification classification
|
||||
- `wip` Under development
|
||||
- `types` type
|
||||
|
||||
## Browser support
|
||||
|
||||
The `Chrome 80+` browser is recommended for local development
|
||||
|
||||
Support modern browsers, not IE
|
||||
|
||||
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/archive/internet-explorer_9-11/internet-explorer_9-11_48x48.png" alt=" Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>IE | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt=" Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari |
|
||||
| :-: | :-: | :-: | :-: | :-: |
|
||||
| not support | last 2 versions | last 2 versions | last 2 versions | last 2 versions |
|
||||
|
||||
## Donate
|
||||
|
||||
If you find this project helpful, welcome sponsorship to show your support~
|
||||
|
||||
[Paypal Me](https://www.paypal.com/paypalme/ckl94)
|
||||
|
||||
<img src="https://github.com/kailong321200875/my-image/raw/master/pay.jpg" />
|
||||
|
||||
### My QR code
|
||||
|
||||
If you have any project cooperation or outsourcing, please scan the code to add me as a friend and leave a note of your purpose.
|
||||
|
||||
<img src="https://github.com/kailong321200875/my-image/raw/master/me.jpg" />
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
@@ -0,0 +1,148 @@
|
||||
<div align="center"> <a href="https://github.com/kailong321200875/vue-element-plus-admin"> <img width="100" src="./public/logo.png"> </a> <br> <br>
|
||||
|
||||
[](LICENSE) [](repo-size) [](last-commit) [](stars) [](forks) [](release) [](watchers)
|
||||
|
||||
<h1>vue-element-plus-admin</h1>
|
||||
</div>
|
||||
|
||||
[English](./README.md) | **中文**
|
||||
|
||||
## 介绍
|
||||
|
||||
vue-element-plus-admin 是一个基于 `element-plus` 免费开源的中后台模版。使用了最新的`vue3`,`vite`,`TypeScript`等主流技术开发,开箱即用的中后台前端解决方案,可以用来作为项目的启动模版,也可用于学习参考。并且时刻关注着最新技术动向,尽可能的第一时间更新。
|
||||
|
||||
vue-element-plus-admin 的定位是后台集成方案,不太适合当基础模板来进行二次开发。因为集成了很多你可能用不到的功能,会造成不少的代码冗余。如果你的项目不关注这方面的问题,也可以直接基于它进行二次开发。
|
||||
|
||||
如需要基础模版,请切换到 `mini` 分支,`mini` 只简单集成了一些如:布局、动态菜单等常用布局功能,更适合开发者进行二次开发。
|
||||
|
||||
## 特性
|
||||
|
||||
- **最新技术栈**:使用 Vue3/vite4 等前端前沿技术开发
|
||||
- **TypeScript**: 应用程序级 JavaScript 的语言
|
||||
- **主题**: 可配置的主题
|
||||
- **国际化**:内置完善的国际化方案
|
||||
- **自定义数据** 内置 Mock 数据方案
|
||||
- **权限** 内置完善的动态路由权限生成方案
|
||||
- **组件** 二次封装了多个常用的组件
|
||||
- **示例** 内置丰富的示例
|
||||
|
||||
## 预览
|
||||
|
||||
- [vue-element-plus-admin](https://element-plus-admin.cn/) - 完整版 github 站点
|
||||
- [vue-element-plus-admin](https://kailong110120130.gitee.io/vue-element-plus-admin) - 完整版 gitee 站点
|
||||
|
||||
帐号:**admin/admin**
|
||||
|
||||
在线例子默认不适用菜单过滤,而是直接使用静态路由表
|
||||
|
||||
## 文档
|
||||
|
||||
[文档地址 Github](https://element-plus-admin-doc.cn/)
|
||||
|
||||
[文档地址 Gitee](https://kailong110120130.gitee.io/vue-element-plus-admin-doc)
|
||||
|
||||
## 前序准备
|
||||
|
||||
- [node](http://nodejs.org/) 和 [git](https://git-scm.com/) - 项目开发环境
|
||||
- [Vite](https://vitejs.dev/) - 熟悉 vite 特性
|
||||
- [Vue3](https://v3.vuejs.org/) - 熟悉 Vue 基础语法
|
||||
- [TypeScript](https://www.typescriptlang.org/) - 熟悉 `TypeScript` 基本语法
|
||||
- [Es6+](http://es6.ruanyifeng.com/) - 熟悉 es6 基本语法
|
||||
- [Vue-Router-Next](https://next.router.vuejs.org/) - 熟悉 vue-router 基本使用
|
||||
- [Element-Plus](https://element-plus.org/) - element-plus 基本使用
|
||||
- [Mock.js](https://github.com/nuysoft/Mock) - mockjs 基本语法
|
||||
|
||||
## 安装和使用
|
||||
|
||||
- 获取代码
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kailong321200875/vue-element-plus-admin.git
|
||||
```
|
||||
|
||||
- 安装依赖
|
||||
|
||||
```bash
|
||||
cd vue-element-plus-admin
|
||||
|
||||
pnpm install
|
||||
|
||||
```
|
||||
|
||||
- 运行
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
- 打包
|
||||
|
||||
```bash
|
||||
pnpm run build:pro
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
|
||||
[更新日志](./CHANGELOG.md)
|
||||
|
||||
## 如何贡献
|
||||
|
||||
<a href="https://github.com/kailong321200875/vue-element-plus-admin/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kailong321200875/vue-element-plus-admin" />
|
||||
</a>
|
||||
|
||||
你可以[提一个 issue](https://github.com/kailong321200875/vue-element-plus-admin/issues/new) 或者提交一个 Pull Request。
|
||||
|
||||
**Pull Request:**
|
||||
|
||||
1. Fork 代码
|
||||
2. 创建自己的分支: `git checkout -b feat/xxxx`
|
||||
3. 提交你的修改: `git commit -am 'feat(function): add xxxxx'`
|
||||
4. 推送您的分支: `git push origin feat/xxxx`
|
||||
5. 提交 `pull request`
|
||||
|
||||
## Git 贡献提交规范
|
||||
|
||||
- `feat` 新功能
|
||||
- `fix` 修补 bug
|
||||
- `docs` 文档
|
||||
- `style` 格式、样式(不影响代码运行的变动)
|
||||
- `refactor` 重构(即不是新增功能,也不是修改 BUG 的代码)
|
||||
- `perf` 优化相关,比如提升性能、体验
|
||||
- `test` 添加测试
|
||||
- `build` 编译相关的修改,对项目构建或者依赖的改动
|
||||
- `ci` 持续集成修改
|
||||
- `chore` 构建过程或辅助工具的变动
|
||||
- `revert` 回滚到上一个版本
|
||||
- `workflow` 工作流改进
|
||||
- `mod` 不确定分类的修改
|
||||
- `wip` 开发中
|
||||
- `types` 类型
|
||||
|
||||
## 浏览器支持
|
||||
|
||||
本地开发推荐使用 `Chrome 80+` 浏览器
|
||||
|
||||
支持现代浏览器, 不支持 IE
|
||||
|
||||
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/archive/internet-explorer_9-11/internet-explorer_9-11_48x48.png" alt=" Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>IE | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt=" Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari |
|
||||
| :-: | :-: | :-: | :-: | :-: |
|
||||
| not support | last 2 versions | last 2 versions | last 2 versions | last 2 versions |
|
||||
|
||||
## Donate
|
||||
|
||||
如果你觉得这个项目有帮助,欢迎赞助以示支持~
|
||||
|
||||
[Paypal Me](https://www.paypal.com/paypalme/ckl94)
|
||||
|
||||
<img src="https://gitee.com/kailong110120130/my-image/raw/master/pay.jpg" />
|
||||
|
||||
### 我的二维码
|
||||
|
||||
如有项目合作或项目外包,扫码加我好友,请备注来意。
|
||||
|
||||
<img src="https://gitee.com/kailong110120130/my-image/raw/master/me.jpg" />
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](./LICENSE)
|
||||
@@ -0,0 +1,17 @@
|
||||
POST http://localhost:8081/promo/login HTTP/1.1
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
|
||||
{"username":"15375298377","password":"123456"}
|
||||
|
||||
|
||||
### 获取卡密列表
|
||||
GET http://localhost:8081/promo/card/list
|
||||
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImQ4MWI5OTM5LTUwZWItNGFiNC05NGIxLTEwOWFiYzY5NzI5MCJ9.RHXzv6Ao6hUl0fwY-Zhszz1-Jda6ceZybjmFddKFLZQbjufYkZd167ZCmlU9SbcrZ3Ec8TIt4XqSS7zb7fVHtQ
|
||||
|
||||
|
||||
### 生成卡密
|
||||
|
||||
POST http://localhost:8081/promo/generateCard/6/1
|
||||
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImQ4MWI5OTM5LTUwZWItNGFiNC05NGIxLTEwOWFiYzY5NzI5MCJ9.RHXzv6Ao6hUl0fwY-Zhszz1-Jda6ceZybjmFddKFLZQbjufYkZd167ZCmlU9SbcrZ3Ec8TIt4XqSS7zb7fVHtQ
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
POST http://localhost:8081/promo/register HTTP/1.1
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
|
||||
{"username":"17688554522","password":"123456", "contactInformation": "hello"}
|
||||
@@ -0,0 +1,11 @@
|
||||
POST http://localhost:8081/promo/login HTTP/1.1
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
|
||||
{"username":"15375298377","password":"123456"}
|
||||
|
||||
|
||||
### 分享域名
|
||||
|
||||
GET http://localhost:8081/promo/getsharedomain HTTP/1.1
|
||||
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImEwMmU0Y2MyLWM5MDctNGU4OS1iNTkxLTk1Mjk1MGI2NjRmNCJ9._imhNdbqREBrYv-Kx73VN0S5U7Pr1zMvvMLEMXHf77uKOLO1Y0NnWRuTvSQlYp1T1KMVcNxEChSLj7I-Xex2wA
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
POST http://localhost:8081/api/login HTTP/1.1
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
|
||||
|
||||
{"username":"13545419205","password":"123456"}
|
||||
|
||||
|
||||
### 兑换红包卡密
|
||||
|
||||
GET http://localhost:8081/api/bonus/receiveRedPacket/A628920554234AFDB539398823835378
|
||||
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjY1MjA4OTYwLTA3NDQtNDQxZS05YzA1LWJjNTc2ZTQyN2RiMyJ9.Ogqmm8iTgiPhc8b7iDxz_a2atBtAgbmwqzi1M5ogLEpAVFAv91N0Ygo-1_yUVioPVn0WTWVJvJnIW42-4CgIJg
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'type-enum': [
|
||||
2,
|
||||
'always',
|
||||
[
|
||||
'feat', // 新功能(feature)
|
||||
'fix', // 修补bug
|
||||
'docs', // 文档(documentation)
|
||||
'style', // 格式、样式(不影响代码运行的变动)
|
||||
'refactor', // 重构(即不是新增功能,也不是修改BUG的代码)
|
||||
'perf', // 优化相关,比如提升性能、体验
|
||||
'test', // 添加测试
|
||||
'ci', // 持续集成修改
|
||||
'chore', // 构建过程或辅助工具的变动
|
||||
'revert', // 回滚到上一个版本
|
||||
'workflow', // 工作流改进
|
||||
'mod', // 不确定分类的修改
|
||||
'wip', // 开发中
|
||||
'types', // 类型修改
|
||||
'release' // 版本发布
|
||||
]
|
||||
],
|
||||
'subject-full-stop': [0, 'never'],
|
||||
'subject-case': [0, 'never']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
vue-element-plus-admin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
- /app/node_modules
|
||||
- .:/app
|
||||
@@ -0,0 +1,82 @@
|
||||
// 引入vue模版的eslint
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import eslint from '@eslint/js'
|
||||
// ts-eslint解析器,使 eslint 可以解析 ts 语法
|
||||
import tseslint from 'typescript-eslint'
|
||||
// vue文件解析器
|
||||
import vueParser from 'vue-eslint-parser'
|
||||
import prettier from 'eslint-plugin-prettier'
|
||||
|
||||
export default tseslint.config({
|
||||
// ignores: ['node_modules', 'prettier.config.cjs', 'dist*'],
|
||||
files: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.vue'],
|
||||
// tseslint.config添加了extends扁平函数,直接用。否则是eslint9.0版本是没有extends的
|
||||
extends: [
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...pluginVue.configs['flat/essential']
|
||||
],
|
||||
plugins: {
|
||||
prettier
|
||||
},
|
||||
languageOptions: {
|
||||
parser: vueParser, // 使用vue解析器,这个可以识别vue文件
|
||||
parserOptions: {
|
||||
parser: tseslint.parser, // 在vue文件上使用ts解析器
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020,
|
||||
ecmaFeatures: {
|
||||
jsx: true
|
||||
}
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'no-useless-escape': 0,
|
||||
'no-undef': 0,
|
||||
'@typescript-eslint/no-unused-expressions': 0,
|
||||
'@typescript-eslint/no-unsafe-function-type': 0,
|
||||
'vue/no-setup-props-destructure': 0,
|
||||
'vue/script-setup-uses-vars': 1,
|
||||
'vue/no-reserved-component-names': 0,
|
||||
'@typescript-eslint/ban-ts-ignore': 0,
|
||||
'@typescript-eslint/explicit-function-return-type': 0,
|
||||
'@typescript-eslint/no-explicit-any': 0,
|
||||
'@typescript-eslint/no-var-requires': 0,
|
||||
'@typescript-eslint/no-empty-function': 0,
|
||||
'vue/custom-event-name-casing': 0,
|
||||
'no-use-before-define': 0,
|
||||
'@typescript-eslint/no-use-before-define': 0,
|
||||
'@typescript-eslint/ban-ts-comment': 0,
|
||||
'@typescript-eslint/ban-types': 0,
|
||||
'@typescript-eslint/no-non-null-assertion': 0,
|
||||
'@typescript-eslint/explicit-module-boundary-types': 0,
|
||||
'@typescript-eslint/no-unused-vars': 0,
|
||||
'no-unused-vars': 0,
|
||||
'space-before-function-paren': 0,
|
||||
'vue/attributes-order': 0,
|
||||
'vue/one-component-per-file': 0,
|
||||
'vue/html-closing-bracket-newline': 0,
|
||||
'vue/max-attributes-per-line': 0,
|
||||
'vue/multiline-html-element-content-newline': 0,
|
||||
'vue/singleline-html-element-content-newline': 0,
|
||||
'vue/attribute-hyphenation': 0,
|
||||
'vue/require-default-prop': 0,
|
||||
'vue/require-explicit-emits': 0,
|
||||
'vue/html-self-closing': [
|
||||
1,
|
||||
{
|
||||
html: {
|
||||
void: 'always',
|
||||
normal: 'never',
|
||||
component: 'always'
|
||||
},
|
||||
svg: 'always',
|
||||
math: 'always'
|
||||
}
|
||||
],
|
||||
'vue/multi-word-component-names': 0,
|
||||
'vue/no-v-html': 0,
|
||||
'vue/require-toggle-inside-transition': 0
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="data:," />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title><%= title %></title>
|
||||
</head>
|
||||
<body>
|
||||
<link rel="stylesheet" href="/tac/css/tac.css" />
|
||||
<script src="/tac/js/tac.min.js"></script>
|
||||
<div id="app">
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer'
|
||||
|
||||
const modules = import.meta.glob('./**/*.mock.ts', {
|
||||
import: 'default',
|
||||
eager: true
|
||||
})
|
||||
|
||||
const mockModules: any[] = []
|
||||
Object.keys(modules).forEach(async (key) => {
|
||||
if (key.includes('_')) {
|
||||
return
|
||||
}
|
||||
mockModules.push(...(modules[key] as any))
|
||||
})
|
||||
|
||||
export function setupProdMockServer() {
|
||||
createProdMockServer(mockModules)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { SUCCESS_CODE } from '@/constants'
|
||||
import { MockMethod } from 'vite-plugin-mock'
|
||||
|
||||
const timeout = 1000
|
||||
|
||||
export default [
|
||||
// 分析页统计接口
|
||||
{
|
||||
url: '/mock/analysis/total',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: {
|
||||
users: 102400,
|
||||
messages: 81212,
|
||||
moneys: 9280,
|
||||
shoppings: 13600
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 用户来源
|
||||
{
|
||||
url: '/mock/analysis/userAccessSource',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: [
|
||||
{ value: 1000, name: 'analysis.directAccess' },
|
||||
{ value: 310, name: 'analysis.mailMarketing' },
|
||||
{ value: 234, name: 'analysis.allianceAdvertising' },
|
||||
{ value: 135, name: 'analysis.videoAdvertising' },
|
||||
{ value: 1548, name: 'analysis.searchEngines' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
// 每周用户活跃量
|
||||
{
|
||||
url: '/mock/analysis/weeklyUserActivity',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: [
|
||||
{ value: 13253, name: 'analysis.monday' },
|
||||
{ value: 34235, name: 'analysis.tuesday' },
|
||||
{ value: 26321, name: 'analysis.wednesday' },
|
||||
{ value: 12340, name: 'analysis.thursday' },
|
||||
{ value: 24643, name: 'analysis.friday' },
|
||||
{ value: 1322, name: 'analysis.saturday' },
|
||||
{ value: 1324, name: 'analysis.sunday' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
// 每月销售额
|
||||
{
|
||||
url: '/mock/analysis/monthlySales',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: [
|
||||
{ estimate: 100, actual: 120, name: 'analysis.january' },
|
||||
{ estimate: 120, actual: 82, name: 'analysis.february' },
|
||||
{ estimate: 161, actual: 91, name: 'analysis.march' },
|
||||
{ estimate: 134, actual: 154, name: 'analysis.april' },
|
||||
{ estimate: 105, actual: 162, name: 'analysis.may' },
|
||||
{ estimate: 160, actual: 140, name: 'analysis.june' },
|
||||
{ estimate: 165, actual: 145, name: 'analysis.july' },
|
||||
{ estimate: 114, actual: 250, name: 'analysis.august' },
|
||||
{ estimate: 163, actual: 134, name: 'analysis.september' },
|
||||
{ estimate: 185, actual: 56, name: 'analysis.october' },
|
||||
{ estimate: 118, actual: 99, name: 'analysis.november' },
|
||||
{ estimate: 123, actual: 123, name: 'analysis.december' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
] as MockMethod[]
|
||||
@@ -0,0 +1,206 @@
|
||||
import { toAnyString } from '@/utils'
|
||||
import Mock from 'mockjs'
|
||||
import { SUCCESS_CODE } from '@/constants'
|
||||
|
||||
const departmentList: any = []
|
||||
|
||||
const citys = ['厦门总公司', '北京分公司', '上海分公司', '福州分公司', '深圳分公司', '杭州分公司']
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
departmentList.push({
|
||||
// 部门名称
|
||||
departmentName: citys[i],
|
||||
id: toAnyString(),
|
||||
createTime: '@datetime',
|
||||
// 状态
|
||||
status: Mock.Random.integer(0, 1),
|
||||
// 备注
|
||||
remark: '@cword(10, 15)',
|
||||
children: [
|
||||
{
|
||||
// 部门名称
|
||||
departmentName: '研发部',
|
||||
id: toAnyString(),
|
||||
createTime: '@datetime',
|
||||
// 状态
|
||||
status: Mock.Random.integer(0, 1),
|
||||
// 备注
|
||||
remark: '@cword(10, 15)'
|
||||
},
|
||||
{
|
||||
// 部门名称
|
||||
departmentName: '产品部',
|
||||
id: toAnyString(),
|
||||
createTime: '@datetime',
|
||||
// 状态
|
||||
status: Mock.Random.integer(0, 1),
|
||||
// 备注
|
||||
remark: '@cword(10, 15)'
|
||||
},
|
||||
{
|
||||
// 部门名称
|
||||
departmentName: '运营部',
|
||||
id: toAnyString(),
|
||||
createTime: '@datetime',
|
||||
// 状态
|
||||
status: Mock.Random.integer(0, 1),
|
||||
// 备注
|
||||
remark: '@cword(10, 15)'
|
||||
},
|
||||
{
|
||||
// 部门名称
|
||||
departmentName: '市场部',
|
||||
id: toAnyString(),
|
||||
createTime: '@datetime',
|
||||
// 状态
|
||||
status: Mock.Random.integer(0, 1),
|
||||
// 备注
|
||||
remark: '@cword(10, 15)'
|
||||
},
|
||||
{
|
||||
// 部门名称
|
||||
departmentName: '销售部',
|
||||
id: toAnyString(),
|
||||
createTime: '@datetime',
|
||||
// 状态
|
||||
status: Mock.Random.integer(0, 1),
|
||||
// 备注
|
||||
remark: '@cword(10, 15)'
|
||||
},
|
||||
{
|
||||
// 部门名称
|
||||
departmentName: '客服部',
|
||||
id: toAnyString(),
|
||||
createTime: '@datetime',
|
||||
// 状态
|
||||
status: Mock.Random.integer(0, 1),
|
||||
// 备注
|
||||
remark: '@cword(10, 15)'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export default [
|
||||
// 列表接口
|
||||
{
|
||||
url: '/mock/department/list',
|
||||
method: 'get',
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: {
|
||||
list: departmentList
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
url: '/mock/department/table/list',
|
||||
method: 'get',
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: {
|
||||
list: departmentList,
|
||||
total: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
url: '/mock/department/users',
|
||||
method: 'get',
|
||||
timeout: 1000,
|
||||
response: ({ query }) => {
|
||||
const { pageSize } = query
|
||||
// 根据pageSize来创建数据
|
||||
const mockList: any = []
|
||||
for (let i = 0; i < pageSize; i++) {
|
||||
mockList.push(
|
||||
Mock.mock({
|
||||
// 用户名
|
||||
username: '@cname',
|
||||
// 账号
|
||||
account: '@first',
|
||||
// 邮箱
|
||||
email: '@EMAIL',
|
||||
// 创建时间
|
||||
createTime: '@datetime',
|
||||
// 用户id
|
||||
id: toAnyString()
|
||||
})
|
||||
)
|
||||
}
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: {
|
||||
total: 100,
|
||||
list: mockList
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 保存接口
|
||||
{
|
||||
url: '/mock/department/user/save',
|
||||
method: 'post',
|
||||
timeout: 1000,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: 'success'
|
||||
}
|
||||
}
|
||||
},
|
||||
// 删除接口
|
||||
{
|
||||
url: '/mock/department/user/delete',
|
||||
method: 'post',
|
||||
response: ({ body }) => {
|
||||
const ids = body.ids
|
||||
if (!ids) {
|
||||
return {
|
||||
code: 500,
|
||||
message: '请选择需要删除的数据'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: 'success'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 保存接口
|
||||
{
|
||||
url: '/mock/department/save',
|
||||
method: 'post',
|
||||
timeout: 1000,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: 'success'
|
||||
}
|
||||
}
|
||||
},
|
||||
// 删除接口
|
||||
{
|
||||
url: '/mock/department/delete',
|
||||
method: 'post',
|
||||
response: ({ body }) => {
|
||||
const ids = body.ids
|
||||
if (!ids) {
|
||||
return {
|
||||
code: 500,
|
||||
message: '请选择需要删除的数据'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: 'success'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
import { SUCCESS_CODE } from '@/constants'
|
||||
|
||||
const timeout = 1000
|
||||
|
||||
const dictObj: Recordable = {
|
||||
importance: [
|
||||
{
|
||||
value: 0,
|
||||
label: 'tableDemo.commonly'
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: 'tableDemo.good'
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: 'tableDemo.important'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default [
|
||||
// 字典接口
|
||||
{
|
||||
url: '/mock/dict/list',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: dictObj
|
||||
}
|
||||
}
|
||||
},
|
||||
// 获取某个字典
|
||||
{
|
||||
url: '/mock/dict/one',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: [
|
||||
{
|
||||
label: 'test1',
|
||||
value: 0
|
||||
},
|
||||
{
|
||||
label: 'test2',
|
||||
value: 1
|
||||
},
|
||||
{
|
||||
label: 'test3',
|
||||
value: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,357 @@
|
||||
import Mock from 'mockjs'
|
||||
import { SUCCESS_CODE } from '@/constants'
|
||||
|
||||
const timeout = 1000
|
||||
|
||||
export default [
|
||||
// 列表接口
|
||||
{
|
||||
url: '/mock/menu/list',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: {
|
||||
list: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: '#',
|
||||
redirect: '/dashboard/analysis',
|
||||
name: 'Dashboard',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 1,
|
||||
type: 0,
|
||||
parentId: undefined,
|
||||
title: '首页',
|
||||
meta: {
|
||||
title: '首页',
|
||||
icon: 'vi-ant-design:dashboard-filled',
|
||||
alwaysShow: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'analysis',
|
||||
component: 'views/Dashboard/Analysis',
|
||||
name: 'Analysis',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 2,
|
||||
type: 1,
|
||||
parentId: 1,
|
||||
title: '分析页',
|
||||
permissionList: [
|
||||
{
|
||||
id: 1,
|
||||
label: '新增',
|
||||
value: 'add'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: '编辑',
|
||||
value: 'edit'
|
||||
}
|
||||
],
|
||||
meta: {
|
||||
title: '分析页',
|
||||
noCache: true,
|
||||
permission: ['add', 'edit']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'workplace',
|
||||
component: 'views/Dashboard/Workplace',
|
||||
name: 'Workplace',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 3,
|
||||
type: 1,
|
||||
parentId: 1,
|
||||
title: '工作台',
|
||||
permissionList: [
|
||||
{
|
||||
id: 1,
|
||||
label: '新增',
|
||||
value: 'add'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: '编辑',
|
||||
value: 'edit'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: '删除',
|
||||
value: 'delete'
|
||||
}
|
||||
],
|
||||
meta: {
|
||||
title: '工作台',
|
||||
noCache: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/external-link',
|
||||
component: '#',
|
||||
meta: {
|
||||
title: '文档',
|
||||
icon: 'vi-clarity:document-solid'
|
||||
},
|
||||
name: 'ExternalLink',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 4,
|
||||
type: 0,
|
||||
parentId: undefined,
|
||||
title: '文档',
|
||||
children: [
|
||||
{
|
||||
path: 'https://element-plus-admin-doc.cn/',
|
||||
name: 'DocumentLink',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 5,
|
||||
type: 1,
|
||||
parentId: 4,
|
||||
title: '文档',
|
||||
meta: {
|
||||
title: '文档'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/level',
|
||||
component: '#',
|
||||
redirect: '/level/menu1/menu1-1/menu1-1-1',
|
||||
name: 'Level',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 6,
|
||||
type: 0,
|
||||
parentId: undefined,
|
||||
title: '菜单',
|
||||
meta: {
|
||||
title: '菜单',
|
||||
icon: 'vi-carbon:skill-level-advanced'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'menu1',
|
||||
name: 'Menu1',
|
||||
component: '##',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 7,
|
||||
type: 0,
|
||||
parentId: 6,
|
||||
title: '菜单1',
|
||||
redirect: '/level/menu1/menu1-1/menu1-1-1',
|
||||
meta: {
|
||||
title: '菜单1'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'menu1-1',
|
||||
name: 'Menu11',
|
||||
component: '##',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 8,
|
||||
type: 0,
|
||||
parentId: 7,
|
||||
title: '菜单1-1',
|
||||
redirect: '/level/menu1/menu1-1/menu1-1-1',
|
||||
meta: {
|
||||
title: '菜单1-1',
|
||||
alwaysShow: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'menu1-1-1',
|
||||
name: 'Menu111',
|
||||
component: 'views/Level/Menu111',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 9,
|
||||
type: 1,
|
||||
parentId: 8,
|
||||
title: '菜单1-1-1',
|
||||
meta: {
|
||||
title: '菜单1-1-1'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'menu1-2',
|
||||
name: 'Menu12',
|
||||
component: 'views/Level/Menu12',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 10,
|
||||
type: 1,
|
||||
parentId: 7,
|
||||
title: '菜单1-2',
|
||||
meta: {
|
||||
title: '菜单1-2'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'menu2',
|
||||
name: 'Menu2Demo',
|
||||
component: 'views/Level/Menu2',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 11,
|
||||
type: 1,
|
||||
parentId: 6,
|
||||
title: '菜单2',
|
||||
meta: {
|
||||
title: '菜单2'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/example',
|
||||
component: '#',
|
||||
redirect: '/example/example-dialog',
|
||||
name: 'Example',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 12,
|
||||
type: 0,
|
||||
parentId: undefined,
|
||||
title: '综合示例',
|
||||
meta: {
|
||||
title: '综合示例',
|
||||
icon: 'vi-ep:management',
|
||||
alwaysShow: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'example-dialog',
|
||||
component: 'views/Example/Dialog/ExampleDialog',
|
||||
name: 'ExampleDialog',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 13,
|
||||
type: 1,
|
||||
parentId: 12,
|
||||
title: '综合示例-弹窗',
|
||||
permissionList: [
|
||||
{
|
||||
id: 1,
|
||||
label: '新增',
|
||||
value: 'add'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: '编辑',
|
||||
value: 'edit'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: '删除',
|
||||
value: 'delete'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: '查看',
|
||||
value: 'view'
|
||||
}
|
||||
],
|
||||
meta: {
|
||||
title: '综合示例-弹窗'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'example-page',
|
||||
component: 'views/Example/Page/ExamplePage',
|
||||
name: 'ExamplePage',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 14,
|
||||
type: 1,
|
||||
parentId: 12,
|
||||
title: '综合示例-页面',
|
||||
permissionList: [
|
||||
{
|
||||
id: 1,
|
||||
label: '新增',
|
||||
value: 'add'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: '编辑',
|
||||
value: 'edit'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: '删除',
|
||||
value: 'delete'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: '查看',
|
||||
value: 'view'
|
||||
}
|
||||
],
|
||||
meta: {
|
||||
title: '综合示例-页面'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'example-add',
|
||||
component: 'views/Example/Page/ExampleAdd',
|
||||
name: 'ExampleAdd',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 15,
|
||||
type: 1,
|
||||
parentId: 12,
|
||||
title: '综合示例-新增',
|
||||
meta: {
|
||||
title: '综合示例-新增',
|
||||
noTagsView: true,
|
||||
noCache: true,
|
||||
hidden: true,
|
||||
showMainRoute: true,
|
||||
activeMenu: '/example/example-page'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'example-edit',
|
||||
component: 'views/Example/Page/ExampleEdit',
|
||||
name: 'ExampleEdit',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 16,
|
||||
type: 1,
|
||||
parentId: 12,
|
||||
title: '综合示例-编辑',
|
||||
meta: {
|
||||
title: '综合示例-编辑',
|
||||
noTagsView: true,
|
||||
noCache: true,
|
||||
hidden: true,
|
||||
showMainRoute: true,
|
||||
activeMenu: '/example/example-page'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'example-detail',
|
||||
component: 'views/Example/Page/ExampleDetail',
|
||||
name: 'ExampleDetail',
|
||||
status: Mock.Random.integer(0, 1),
|
||||
id: 17,
|
||||
type: 1,
|
||||
parentId: 12,
|
||||
title: '综合示例-详情',
|
||||
meta: {
|
||||
title: '综合示例-详情',
|
||||
noTagsView: true,
|
||||
noCache: true,
|
||||
hidden: true,
|
||||
showMainRoute: true,
|
||||
activeMenu: '/example/example-page'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
import { SUCCESS_CODE } from '@/constants'
|
||||
|
||||
const timeout = 600000
|
||||
|
||||
export default [
|
||||
{
|
||||
url: '/mock/request/1',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: 'request-1'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
url: '/mock/request/2',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: 'request-2'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
url: '/mock/request/3',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: 'request-3'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
url: '/mock/request/4',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: 'request-4'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
url: '/mock/request/5',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: 'request-5'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
url: '/mock/request/expired',
|
||||
method: 'get',
|
||||
timeout: 0,
|
||||
response: () => {
|
||||
return {
|
||||
code: 401,
|
||||
message: 'token expired'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,319 @@
|
||||
import Mock from 'mockjs'
|
||||
import { SUCCESS_CODE } from '@/constants'
|
||||
import { toAnyString } from '@/utils'
|
||||
|
||||
const timeout = 1000
|
||||
const count = 100
|
||||
|
||||
const baseContent =
|
||||
'<p>I am testing data, I am testing data.</p><p><img src="https://wpimg.wallstcn.com/4c69009c-0fd4-4153-b112-6cb53d1cf943"></p>'
|
||||
|
||||
interface ListProps {
|
||||
id: string
|
||||
author: string
|
||||
title: string
|
||||
content: string
|
||||
importance: number
|
||||
display_time: any
|
||||
pageviews: number
|
||||
image_uri: string
|
||||
video_uri?: string
|
||||
}
|
||||
|
||||
interface TreeListProps {
|
||||
id: string
|
||||
author: string
|
||||
title: string
|
||||
content: string
|
||||
importance: number
|
||||
display_time: any
|
||||
image_uri: string
|
||||
pageviews: number
|
||||
video_uri?: string
|
||||
children?: TreeListProps[]
|
||||
}
|
||||
|
||||
let List: ListProps[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
List.push(
|
||||
Mock.mock({
|
||||
id: toAnyString(),
|
||||
// timestamp: +Mock.Random.date('T'),
|
||||
author: '@first',
|
||||
title: '@title(5, 10)',
|
||||
content: baseContent,
|
||||
importance: '@integer(1, 3)',
|
||||
display_time: '@datetime',
|
||||
pageviews: '@integer(100, 500)',
|
||||
image_uri: Mock.Random.image('@integer(100, 500)x@integer(100, 500)'),
|
||||
video_uri:
|
||||
'//sf1-cdn-tos.huoshanstatic.com/obj/media-fe/xgplayer_doc_video/mp4/xgplayer-demo-720p.mp4'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const treeList: TreeListProps[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
treeList.push(
|
||||
Mock.mock({
|
||||
id: toAnyString(),
|
||||
// timestamp: +Mock.Random.date('T'),
|
||||
author: '@first',
|
||||
title: '@title(5, 10)',
|
||||
content: baseContent,
|
||||
importance: '@integer(1, 3)',
|
||||
display_time: '@datetime',
|
||||
pageviews: '@integer(300, 5000)',
|
||||
image_uri: Mock.Random.image('@integer(100, 500)x@integer(100, 500)'),
|
||||
children: [
|
||||
{
|
||||
id: toAnyString(),
|
||||
// timestamp: +Mock.Random.date('T'),
|
||||
author: '@first',
|
||||
title: '@title(5, 10)',
|
||||
content: baseContent,
|
||||
importance: '@integer(1, 3)',
|
||||
display_time: '@datetime',
|
||||
pageviews: '@integer(300, 5000)',
|
||||
image_uri: Mock.Random.image('@integer(100, 500)x@integer(100, 500)'),
|
||||
children: [
|
||||
{
|
||||
id: toAnyString(),
|
||||
// timestamp: +Mock.Random.date('T'),
|
||||
author: '@first',
|
||||
title: '@title(5, 10)',
|
||||
content: baseContent,
|
||||
importance: '@integer(1, 3)',
|
||||
display_time: '@datetime',
|
||||
pageviews: '@integer(300, 5000)',
|
||||
image_uri: Mock.Random.image('@integer(100, 500)x@integer(100, 500)')
|
||||
},
|
||||
{
|
||||
id: toAnyString(),
|
||||
// timestamp: +Mock.Random.date('T'),
|
||||
author: '@first',
|
||||
title: '@title(5, 10)',
|
||||
content: baseContent,
|
||||
importance: '@integer(1, 3)',
|
||||
display_time: '@datetime',
|
||||
pageviews: '@integer(300, 5000)',
|
||||
image_uri: Mock.Random.image('@integer(100, 500)x@integer(100, 500)')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: toAnyString(),
|
||||
// timestamp: +Mock.Random.date('T'),
|
||||
author: '@first',
|
||||
title: '@title(5, 10)',
|
||||
content: baseContent,
|
||||
importance: '@integer(1, 3)',
|
||||
display_time: '@datetime',
|
||||
pageviews: '@integer(300, 5000)',
|
||||
image_uri: Mock.Random.image('@integer(100, 500)x@integer(100, 500)')
|
||||
},
|
||||
{
|
||||
id: toAnyString(),
|
||||
// timestamp: +Mock.Random.date('T'),
|
||||
author: '@first',
|
||||
title: '@title(5, 10)',
|
||||
content: baseContent,
|
||||
importance: '@integer(1, 3)',
|
||||
display_time: '@datetime',
|
||||
pageviews: '@integer(300, 5000)',
|
||||
image_uri: Mock.Random.image('@integer(100, 500)x@integer(100, 500)')
|
||||
},
|
||||
{
|
||||
id: toAnyString(),
|
||||
// timestamp: +Mock.Random.date('T'),
|
||||
author: '@first',
|
||||
title: '@title(5, 10)',
|
||||
content: baseContent,
|
||||
importance: '@integer(1, 3)',
|
||||
display_time: '@datetime',
|
||||
pageviews: '@integer(300, 5000)',
|
||||
image_uri: Mock.Random.image('@integer(100, 500)x@integer(100, 500)')
|
||||
}
|
||||
]
|
||||
// image_uri
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const cardList = [
|
||||
{
|
||||
logo: 'https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png',
|
||||
name: 'Alipay',
|
||||
desc: '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。'
|
||||
},
|
||||
{
|
||||
logo: 'https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png',
|
||||
name: 'Angular',
|
||||
desc: '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。'
|
||||
},
|
||||
{
|
||||
logo: 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png',
|
||||
name: 'Bootstrap',
|
||||
desc: '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。'
|
||||
},
|
||||
{
|
||||
logo: 'https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png',
|
||||
name: 'React',
|
||||
desc: '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。'
|
||||
},
|
||||
{
|
||||
logo: 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png',
|
||||
name: 'Vue',
|
||||
desc: '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。'
|
||||
},
|
||||
{
|
||||
logo: 'https://gw.alipayobjects.com/zos/rmsportal/nxkuOJlFJuAUhzlMTCEe.png',
|
||||
name: 'Webpack',
|
||||
desc: '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。'
|
||||
}
|
||||
]
|
||||
|
||||
export default [
|
||||
// 树形列表接口
|
||||
{
|
||||
url: '/mock/example/treeList',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: ({ query }) => {
|
||||
const { title, pageIndex, pageSize } = query
|
||||
const mockList = treeList.filter((item) => {
|
||||
if (title && item.title.indexOf(title) < 0) return false
|
||||
return true
|
||||
})
|
||||
const pageList = mockList.filter(
|
||||
(_, index) => index < pageSize * pageIndex && index >= pageSize * (pageIndex - 1)
|
||||
)
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: {
|
||||
total: mockList.length,
|
||||
list: pageList
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 列表接口
|
||||
{
|
||||
url: '/mock/example/list',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: ({ query }) => {
|
||||
const { title, pageIndex, pageSize } = query
|
||||
const mockList = List.filter((item) => {
|
||||
if (title && item.title.indexOf(title) < 0) return false
|
||||
return true
|
||||
})
|
||||
const pageList = mockList.filter(
|
||||
(_, index) => index < pageSize * pageIndex && index >= pageSize * (pageIndex - 1)
|
||||
)
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: {
|
||||
total: mockList.length,
|
||||
list: pageList
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 保存接口
|
||||
{
|
||||
url: '/mock/example/save',
|
||||
method: 'post',
|
||||
timeout,
|
||||
response: ({ body }) => {
|
||||
if (!body.id) {
|
||||
List = [
|
||||
Object.assign(body, {
|
||||
id: toAnyString()
|
||||
})
|
||||
].concat(List)
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: 'success'
|
||||
}
|
||||
} else {
|
||||
List.map((item) => {
|
||||
if (item.id === body.id) {
|
||||
for (const key in item) {
|
||||
item[key] = body[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: 'success'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 详情接口
|
||||
{
|
||||
url: '/mock/example/detail',
|
||||
method: 'get',
|
||||
response: ({ query }) => {
|
||||
const { id } = query
|
||||
for (const example of List) {
|
||||
if (example.id === id) {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: example
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 删除接口
|
||||
{
|
||||
url: '/mock/example/delete',
|
||||
method: 'post',
|
||||
response: ({ body }) => {
|
||||
const ids = body.ids
|
||||
if (!ids) {
|
||||
return {
|
||||
code: 500,
|
||||
message: '请选择需要删除的数据'
|
||||
}
|
||||
} else {
|
||||
let i = List.length
|
||||
while (i--) {
|
||||
if (ids.indexOf(List[i].id) !== -1) {
|
||||
List.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: 'success'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
url: '/mock/card/list',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: ({ query }) => {
|
||||
const { name, pageIndex, pageSize } = query
|
||||
const mockList = cardList.filter((item) => {
|
||||
if (name && item.name.indexOf(name) < 0) return false
|
||||
return true
|
||||
})
|
||||
const pageList = mockList.filter(
|
||||
(_, index) => index < pageSize * pageIndex && index >= pageSize * (pageIndex - 1)
|
||||
)
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: {
|
||||
total: mockList.length,
|
||||
list: pageList
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,90 @@
|
||||
import { SUCCESS_CODE } from '@/constants'
|
||||
|
||||
const timeout = 1000
|
||||
|
||||
const List: {
|
||||
username: string
|
||||
password: string
|
||||
role: string
|
||||
roleId: string
|
||||
permissions: string | string[]
|
||||
}[] = [
|
||||
{
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
role: 'admin',
|
||||
roleId: '1',
|
||||
permissions: ['*.*.*']
|
||||
},
|
||||
{
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
role: 'test',
|
||||
roleId: '2',
|
||||
permissions: ['example:dialog:create', 'example:dialog:delete']
|
||||
}
|
||||
]
|
||||
|
||||
export default [
|
||||
// 列表接口
|
||||
{
|
||||
url: '/mock/user/list',
|
||||
method: 'get',
|
||||
response: ({ query }) => {
|
||||
const { username, pageIndex, pageSize } = query
|
||||
|
||||
const mockList = List.filter((item) => {
|
||||
if (username && item.username.indexOf(username) < 0) return false
|
||||
return true
|
||||
})
|
||||
const pageList = mockList.filter(
|
||||
(_, index) => index < pageSize * pageIndex && index >= pageSize * (pageIndex - 1)
|
||||
)
|
||||
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: {
|
||||
total: mockList.length,
|
||||
list: pageList
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 登录接口
|
||||
{
|
||||
url: '/mock/user/login',
|
||||
method: 'post',
|
||||
timeout,
|
||||
response: ({ body }) => {
|
||||
const data = body
|
||||
let hasUser = false
|
||||
for (const user of List) {
|
||||
if (user.username === data.username && user.password === data.password) {
|
||||
hasUser = true
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: user
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUser) {
|
||||
return {
|
||||
code: 500,
|
||||
message: '账号或密码错误'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 退出接口
|
||||
{
|
||||
url: '/mock/user/loginOut',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,169 @@
|
||||
import { SUCCESS_CODE } from '@/constants'
|
||||
|
||||
const timeout = 1000
|
||||
|
||||
export default [
|
||||
// 获取统计
|
||||
{
|
||||
url: '/mock/workplace/total',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: {
|
||||
project: 40,
|
||||
access: 2340,
|
||||
todo: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 获取项目
|
||||
{
|
||||
url: '/mock/workplace/project',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: [
|
||||
{
|
||||
name: 'Github',
|
||||
icon: 'akar-icons:github-fill',
|
||||
message: 'workplace.introduction',
|
||||
personal: 'Archer',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
name: 'Vue',
|
||||
icon: 'logos:vue',
|
||||
message: 'workplace.introduction',
|
||||
personal: 'Archer',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
name: 'Angular',
|
||||
icon: 'logos:angular-icon',
|
||||
message: 'workplace.introduction',
|
||||
personal: 'Archer',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
name: 'React',
|
||||
icon: 'logos:react',
|
||||
message: 'workplace.introduction',
|
||||
personal: 'Archer',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
name: 'Webpack',
|
||||
icon: 'logos:webpack',
|
||||
message: 'workplace.introduction',
|
||||
personal: 'Archer',
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
name: 'Vite',
|
||||
icon: 'vscode-icons:file-type-vite',
|
||||
message: 'workplace.introduction',
|
||||
personal: 'Archer',
|
||||
time: new Date()
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
// 获取动态
|
||||
{
|
||||
url: '/mock/workplace/dynamic',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: [
|
||||
{
|
||||
keys: ['workplace.push', 'Github'],
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
keys: ['workplace.push', 'Github'],
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
keys: ['workplace.push', 'Github'],
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
keys: ['workplace.push', 'Github'],
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
keys: ['workplace.push', 'Github'],
|
||||
time: new Date()
|
||||
},
|
||||
{
|
||||
keys: ['workplace.push', 'Github'],
|
||||
time: new Date()
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
// 获取团队信息
|
||||
{
|
||||
url: '/mock/workplace/team',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: [
|
||||
{
|
||||
name: 'Github',
|
||||
icon: 'akar-icons:github-fill'
|
||||
},
|
||||
{
|
||||
name: 'Vue',
|
||||
icon: 'logos:vue'
|
||||
},
|
||||
{
|
||||
name: 'Angular',
|
||||
icon: 'logos:angular-icon'
|
||||
},
|
||||
{
|
||||
name: 'React',
|
||||
icon: 'logos:react'
|
||||
},
|
||||
{
|
||||
name: 'Webpack',
|
||||
icon: 'logos:webpack'
|
||||
},
|
||||
{
|
||||
name: 'Vite',
|
||||
icon: 'vscode-icons:file-type-vite'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
// 获取指数
|
||||
{
|
||||
url: '/mock/workplace/radar',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: [
|
||||
{ name: 'workplace.quote', max: 65, personal: 42, team: 50 },
|
||||
{ name: 'workplace.contribution', max: 160, personal: 30, team: 140 },
|
||||
{ name: 'workplace.hot', max: 300, personal: 20, team: 28 },
|
||||
{ name: 'workplace.yield', max: 130, personal: 35, team: 35 },
|
||||
{ name: 'workplace.follow', max: 100, personal: 80, team: 90 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"name": "vue-element-plus-admin",
|
||||
"version": "2.9.0",
|
||||
"description": "一套基于vue3、element-plus、typesScript、vite4的后台集成方案。",
|
||||
"author": "Archer <502431556@qq.com>",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"i": "pnpm install",
|
||||
"dev": "pnpm vite --mode base",
|
||||
"ts:check": "pnpm vue-tsc --noEmit --skipLibCheck",
|
||||
"build:pro": "pnpm vite build --mode pro",
|
||||
"build:gitee": "pnpm vite build --mode gitee",
|
||||
"build:dev": "pnpm vite build --mode dev",
|
||||
"build:test": "pnpm vite build --mode test",
|
||||
"serve:pro": "pnpm vite preview --mode pro",
|
||||
"serve:dev": "pnpm vite preview --mode dev",
|
||||
"serve:test": "pnpm vite preview --mode test",
|
||||
"npm:check": "pnpx npm-check-updates -u",
|
||||
"clean": "pnpx rimraf node_modules",
|
||||
"clean:cache": "pnpx rimraf node_modules/.cache",
|
||||
"lint:eslint": "eslint . --fix \"src/**/*.{js,ts,tsx,vue,html}\"",
|
||||
"lint:format": "prettier --write --loglevel warn \"src/**/*.{js,ts,json,tsx,css,less,vue,html,md}\"",
|
||||
"lint:style": "stylelint --fix \"**/*.{vue,less,postcss,css,scss}\" --cache --cache-location node_modules/.cache/stylelint/",
|
||||
"lint:lint-staged": "lint-staged -c ./.husky/lintstagedrc.cjs",
|
||||
"prepare": "husky install",
|
||||
"p": "plop",
|
||||
"icon": "esno ./scripts/icon.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@iconify/iconify": "^3.1.1",
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@vueuse/core": "^12.3.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.10",
|
||||
"@zxcvbn-ts/core": "^3.0.4",
|
||||
"animate.css": "^4.1.1",
|
||||
"axios": "^1.7.9",
|
||||
"cropperjs": "^1.6.2",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"driver.js": "^1.3.1",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-wordcloud": "^2.1.0",
|
||||
"element-plus": "2.9.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mitt": "^3.0.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.3.0",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"qs": "^6.13.1",
|
||||
"url": "^0.11.4",
|
||||
"vue": "3.5.13",
|
||||
"vue-draggable-plus": "^0.6.0",
|
||||
"vue-i18n": "11.0.1",
|
||||
"vue-json-pretty": "^2.4.0",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-types": "^5.1.3",
|
||||
"xgplayer": "^3.0.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.6.1",
|
||||
"@commitlint/config-conventional": "^19.6.0",
|
||||
"@iconify/json": "^2.2.293",
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.3",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/inquirer": "^9.0.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mockjs": "^1.0.10",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/qs": "^6.9.17",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.19.1",
|
||||
"@typescript-eslint/parser": "^8.19.1",
|
||||
"@unocss/transformer-variant-group": "^0.65.4",
|
||||
"@vitejs/plugin-legacy": "^6.0.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vitejs/plugin-vue-jsx": "^4.1.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"chalk": "^5.4.1",
|
||||
"consola": "^3.3.3",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-define-config": "^2.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"esno": "^4.8.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"inquirer": "^12.3.0",
|
||||
"less": "^4.2.1",
|
||||
"lint-staged": "^15.3.0",
|
||||
"mockjs": "^1.1.0",
|
||||
"plop": "^4.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-html": "^1.7.0",
|
||||
"postcss-less": "^6.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"rollup": "^4.30.1",
|
||||
"rollup-plugin-visualizer": "^5.14.0",
|
||||
"stylelint": "^16.12.0",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-recommended": "^14.0.1",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"stylelint-order": "^6.0.4",
|
||||
"terser": "^5.37.0",
|
||||
"typescript": "5.7.3",
|
||||
"typescript-eslint": "^8.19.1",
|
||||
"unocss": "^0.65.4",
|
||||
"vite": "6.0.7",
|
||||
"vite-plugin-ejs": "^1.7.0",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-mock": "2.9.6",
|
||||
"vite-plugin-progress": "^0.0.7",
|
||||
"vite-plugin-purge-icons": "^0.10.0",
|
||||
"vite-plugin-style-import": "2.0.0",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vite-plugin-url-copy": "^1.1.4",
|
||||
"vue-tsc": "^2.2.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.3",
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"pnpm": ">=8.1.0"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/kailong321200875/vue-element-plus-admin.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/kailong321200875/vue-element-plus-admin/issues"
|
||||
},
|
||||
"homepage": "https://github.com/kailong321200875/vue-element-plus-admin"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('{{ name }}')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="`${prefixCls}-{{ name }}`">{{ upperFirstName }}</div>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
import {{ upperFirstName }} from './src/{{ upperFirstName }}.vue'
|
||||
|
||||
export { {{ upperFirstName }} }
|
||||
@@ -0,0 +1,38 @@
|
||||
const toUpperCase = (str) => str.charAt(0).toUpperCase() + str.slice(1)
|
||||
|
||||
module.exports = {
|
||||
description: 'Create vue component',
|
||||
prompts: [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: '请输入组件名称(Please enter the component name)'
|
||||
}
|
||||
],
|
||||
actions: (data) => {
|
||||
const { name } = data
|
||||
const upperFirstName = toUpperCase(name)
|
||||
|
||||
const actions = []
|
||||
if (name) {
|
||||
actions.push({
|
||||
type: 'add',
|
||||
path: `./src/components/${upperFirstName}/src/${upperFirstName}.vue`,
|
||||
templateFile: './plop/component/component.hbs',
|
||||
data: {
|
||||
name,
|
||||
upperFirstName
|
||||
}
|
||||
}, {
|
||||
type: 'add',
|
||||
path: `./src/components/${upperFirstName}/index.ts`,
|
||||
templateFile: './plop/component/index.hbs',
|
||||
data: {
|
||||
upperFirstName
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
const toUpperCase = (str) => str.charAt(0).toUpperCase() + str.slice(1)
|
||||
|
||||
module.exports = {
|
||||
description: 'Create vue view',
|
||||
prompts: [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'path',
|
||||
message: '请输入路径(Please enter a path)',
|
||||
default: 'views'
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: '请输入模块名称(Please enter module name)'
|
||||
}
|
||||
],
|
||||
actions: (data) => {
|
||||
const { name, path } = data
|
||||
const upperFirstName = toUpperCase(name)
|
||||
|
||||
const actions = []
|
||||
if (name) {
|
||||
actions.push({
|
||||
type: 'add',
|
||||
path: `./src/${path}/${upperFirstName}.vue`,
|
||||
templateFile: './plop/view/view.hbs',
|
||||
data: {
|
||||
name,
|
||||
upperFirstName
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ContentWrap } from '@/components/ContentWrap'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContentWrap title="{{ upperFirstName }}"> {{ name }} </ContentWrap>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user