1052 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="dark">
<title>星河幸运房</title>
<style>
:root {
--ink: #fff;
--muted: rgba(241, 235, 255, .68);
--line: rgba(255, 255, 255, .1);
--panel: rgba(22, 14, 49, .78);
--violet: #8e5bff;
--pink: #ff5fa7;
--gold: #ffd572;
--green: #68e4b4;
--danger: #ff7c93;
}
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
color: var(--ink);
font-family: Inter, ui-rounded, "SF Pro Rounded", "PingFang SC", system-ui, sans-serif;
background:
radial-gradient(circle at 15% 0%, rgba(128, 66, 255, .34), transparent 34rem),
radial-gradient(circle at 90% 90%, rgba(255, 79, 159, .23), transparent 35rem),
#090616;
overscroll-behavior: none;
}
button, select { font: inherit; }
button { color: inherit; }
.app {
position: relative;
width: min(100%, 460px);
min-height: 100svh;
margin: 0 auto;
overflow: hidden;
background:
radial-gradient(ellipse at 50% 13%, rgba(162, 102, 255, .35), transparent 38%),
linear-gradient(180deg, #231450 0%, #130c32 50%, #0b081b 100%);
box-shadow: 0 0 80px rgba(0, 0, 0, .45);
isolation: isolate;
}
.app::before,
.app::after {
content: "";
position: absolute;
z-index: -1;
border-radius: 999px;
filter: blur(2px);
opacity: .65;
}
.app::before {
width: 280px;
height: 280px;
top: 80px;
left: -130px;
background: radial-gradient(circle, rgba(88, 113, 255, .22), transparent 66%);
}
.app::after {
width: 340px;
height: 340px;
right: -180px;
top: 240px;
background: radial-gradient(circle, rgba(255, 77, 172, .18), transparent 67%);
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: calc(18px + env(safe-area-inset-top)) 18px 12px;
}
.room-title { min-width: 0; }
.room-title h1 {
margin: 0 0 5px;
font-size: 20px;
line-height: 1.2;
letter-spacing: .01em;
}
.room-meta {
display: flex;
align-items: center;
gap: 7px;
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 0 4px rgba(104, 228, 180, .13), 0 0 12px rgba(104, 228, 180, .8);
}
.icon-button {
width: 38px;
height: 38px;
flex: 0 0 38px;
border: 1px solid var(--line);
border-radius: 50%;
background: rgba(255, 255, 255, .08);
cursor: pointer;
transition: transform .18s ease, background .18s ease;
}
.icon-button:active { transform: scale(.94); }
.strategy-strip {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 8px;
margin: 0 16px 12px;
padding: 9px 11px;
border: 1px solid rgba(178, 143, 255, .15);
border-radius: 13px;
color: var(--muted);
background: rgba(13, 8, 34, .36);
font-size: 11px;
}
.strategy-strip strong { color: #eee7ff; font-weight: 700; }
.room-stage {
position: relative;
min-height: 392px;
padding: 4px 20px 16px;
}
.room-stage::before {
content: "";
position: absolute;
width: 210px;
height: 210px;
top: 14px;
left: calc(50% - 105px);
border: 1px solid rgba(197, 165, 255, .12);
border-radius: 50%;
box-shadow: inset 0 0 60px rgba(124, 80, 255, .08), 0 0 70px rgba(104, 77, 255, .1);
}
.seats {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 24px 8px;
padding-top: 24px;
}
.seat {
position: relative;
display: flex;
min-width: 0;
flex-direction: column;
align-items: center;
gap: 7px;
text-align: center;
}
.seat.host {
grid-column: 3;
grid-row: 1;
transform: translateY(-4px);
}
.seat.host::after {
content: "主播";
position: absolute;
top: -9px;
padding: 2px 7px;
border-radius: 999px;
color: #4b2b00;
background: linear-gradient(90deg, #ffdd78, #ffb74f);
font-size: 9px;
font-weight: 800;
box-shadow: 0 4px 12px rgba(255, 189, 71, .3);
}
.seat.sender { grid-column: 1; grid-row: 2; }
.avatar {
position: relative;
display: grid;
width: 52px;
height: 52px;
place-items: center;
border: 2px solid rgba(255, 255, 255, .16);
border-radius: 50%;
background: linear-gradient(145deg, rgba(255,255,255,.15), rgba(255,255,255,.04));
box-shadow: 0 8px 25px rgba(0, 0, 0, .2);
font-size: 23px;
}
.host .avatar {
width: 66px;
height: 66px;
border-color: rgba(255, 210, 113, .74);
background: linear-gradient(145deg, #ff8abc, #845cff);
box-shadow: 0 0 0 5px rgba(255, 196, 102, .08), 0 0 27px rgba(255, 112, 185, .28);
font-size: 29px;
}
.sender .avatar {
border-color: rgba(119, 218, 255, .7);
background: linear-gradient(145deg, #4fc1ff, #6954e8);
}
.empty .avatar {
color: rgba(255, 255, 255, .24);
border-style: dashed;
background: rgba(255, 255, 255, .035);
box-shadow: none;
font-size: 17px;
}
.seat-name {
max-width: 68px;
overflow: hidden;
color: rgba(255,255,255,.82);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.seat-id { color: rgba(255,255,255,.35); font-size: 8px; }
.mic {
position: absolute;
right: -2px;
bottom: 19px;
display: grid;
width: 17px;
height: 17px;
place-items: center;
border-radius: 50%;
background: #6a4cbd;
font-size: 9px;
}
.activity-feed {
position: absolute;
z-index: 3;
right: 17px;
bottom: 9px;
left: 17px;
display: flex;
height: 66px;
flex-direction: column;
justify-content: flex-end;
gap: 5px;
pointer-events: none;
}
.feed-line {
width: fit-content;
max-width: 88%;
overflow: hidden;
padding: 5px 9px;
border-radius: 8px 12px 12px 2px;
color: rgba(255,255,255,.78);
background: rgba(7, 5, 22, .48);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
animation: feed-in .28s ease-out;
}
.feed-line strong { color: var(--gold); }
.drawer {
position: relative;
z-index: 5;
padding: 15px 16px calc(15px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(255,255,255,.09);
border-radius: 23px 23px 0 0;
background: linear-gradient(180deg, rgba(26, 17, 54, .96), rgba(12, 9, 27, .98));
box-shadow: 0 -20px 55px rgba(0, 0, 0, .2);
backdrop-filter: blur(22px);
}
.drawer-head,
.balance-row,
.send-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.drawer-head { margin-bottom: 12px; }
.drawer-title { font-size: 14px; font-weight: 800; }
.balance {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--gold);
font-size: 13px;
font-weight: 800;
}
.coin {
display: inline-grid;
width: 17px;
height: 17px;
place-items: center;
border: 1px solid #ffdc6c;
border-radius: 50%;
color: #7a4b00;
background: linear-gradient(145deg, #fff096, #f0a82d);
font-size: 10px;
box-shadow: 0 0 12px rgba(255, 200, 75, .26);
}
.gift-card {
display: grid;
grid-template-columns: 76px 1fr;
gap: 13px;
align-items: center;
margin-bottom: 13px;
padding: 11px;
border: 1px solid rgba(186, 142, 255, .26);
border-radius: 17px;
background: linear-gradient(110deg, rgba(130, 77, 255, .15), rgba(255, 85, 165, .08));
}
.gift-visual {
position: relative;
display: grid;
width: 76px;
height: 76px;
place-items: center;
border-radius: 19px;
background:
radial-gradient(circle at 60% 30%, rgba(255,255,255,.45), transparent 7%),
radial-gradient(circle, rgba(174, 116, 255, .32), rgba(93, 51, 181, .08));
font-size: 40px;
overflow: hidden;
}
.gift-visual::after {
content: "";
position: absolute;
width: 90px;
height: 11px;
transform: rotate(-34deg) translate(-21px, 29px);
background: linear-gradient(90deg, transparent, rgba(121, 208, 255, .48), rgba(255, 116, 197, .7));
filter: blur(3px);
}
.gift-info h2 { margin: 0 0 4px; font-size: 16px; }
.gift-info p { margin: 0; color: var(--muted); font-size: 11px; }
.gift-info strong { color: var(--gold); }
.count-chips {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 7px;
margin-bottom: 13px;
}
.count-chip {
min-height: 34px;
border: 1px solid var(--line);
border-radius: 10px;
color: var(--muted);
background: rgba(255, 255, 255, .045);
cursor: pointer;
}
.count-chip.active {
border-color: rgba(255, 202, 105, .55);
color: #3b2600;
background: linear-gradient(135deg, #ffe490, #ffb84f);
font-weight: 850;
box-shadow: 0 5px 18px rgba(255, 184, 79, .18);
}
.send-meta { min-width: 0; }
.send-meta .target {
max-width: 180px;
overflow: hidden;
color: rgba(255,255,255,.84);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.send-meta .cost { margin-top: 3px; color: var(--muted); font-size: 10px; }
.send-button {
min-width: 138px;
min-height: 46px;
border: 0;
border-radius: 999px;
color: #fff;
background: linear-gradient(100deg, #8c5bff, #ff5fa8);
box-shadow: 0 10px 28px rgba(218, 78, 196, .25);
cursor: pointer;
font-weight: 850;
letter-spacing: .02em;
transition: filter .2s ease, transform .2s ease;
}
.send-button:active { transform: scale(.97); }
.send-button:disabled { cursor: wait; filter: grayscale(.6) opacity(.55); }
.debug {
margin-top: 13px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 10px;
}
.debug summary { padding: 10px 0 0; cursor: pointer; }
.debug pre {
max-height: 170px;
overflow: auto;
margin: 9px 0 0;
padding: 10px;
border-radius: 10px;
color: #cfc5ed;
background: #090616;
white-space: pre-wrap;
word-break: break-all;
}
.setup-mask {
position: absolute;
z-index: 30;
inset: 0;
display: grid;
place-items: center;
padding: 30px;
background: rgba(8, 5, 21, .84);
backdrop-filter: blur(20px);
transition: opacity .25s ease, visibility .25s ease;
}
.setup-mask.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
.setup-card { width: min(100%, 310px); text-align: center; }
.setup-orbit {
position: relative;
display: grid;
width: 86px;
height: 86px;
margin: 0 auto 20px;
place-items: center;
border: 1px solid rgba(255,255,255,.13);
border-radius: 50%;
font-size: 40px;
}
.setup-orbit::before {
content: "";
position: absolute;
inset: -8px;
border: 2px solid transparent;
border-top-color: var(--pink);
border-right-color: var(--violet);
border-radius: 50%;
animation: spin 1.1s linear infinite;
}
.setup-card h2 { margin: 0 0 8px; font-size: 20px; }
.setup-card p { min-height: 38px; margin: 0 0 18px; color: var(--muted); font-size: 12px; line-height: 1.6; }
.retry-button {
display: none;
width: 100%;
min-height: 44px;
border: 1px solid rgba(255,255,255,.17);
border-radius: 13px;
background: rgba(255,255,255,.09);
cursor: pointer;
}
.retry-button.visible { display: block; }
.meteor-layer,
.celebration-layer {
position: absolute;
z-index: 20;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.meteor-flight {
position: absolute;
width: 45px;
height: 45px;
margin: -22px;
border-radius: 50%;
background: radial-gradient(circle at 65% 35%, #fff 0 8%, #ffd2f0 12%, #d868ff 38%, rgba(119, 89, 255, .1) 65%);
box-shadow: -22px 18px 14px rgba(92, 185, 255, .38), -45px 36px 20px rgba(255, 83, 175, .24), 0 0 22px #d77aff;
animation: meteor var(--flight, .62s) cubic-bezier(.22,.75,.2,1) forwards;
}
.win-banner {
position: absolute;
top: 27%;
right: 18px;
left: 18px;
display: grid;
min-height: 112px;
place-items: center;
padding: 14px;
border: 1px solid rgba(255, 221, 124, .6);
border-radius: 20px;
opacity: 0;
text-align: center;
background:
linear-gradient(100deg, rgba(77, 31, 145, .96), rgba(167, 45, 125, .96)),
#4a2078;
box-shadow: 0 0 50px rgba(255, 92, 194, .28), inset 0 0 35px rgba(255, 220, 112, .08);
transform: scale(.76) translateY(14px);
}
.win-banner.show { animation: banner 1.8s ease forwards; }
.win-banner.big { border-color: #fff0a5; box-shadow: 0 0 85px rgba(255, 201, 82, .58); }
.win-kicker { color: #ffdfa0; font-size: 11px; font-weight: 800; letter-spacing: .18em; }
.win-amount { margin: 4px 0; color: #fff4be; font-size: 29px; font-weight: 950; text-shadow: 0 3px 20px rgba(255, 213, 89, .5); }
.win-detail { color: rgba(255,255,255,.66); font-size: 10px; }
.spark {
position: absolute;
top: -20px;
width: 8px;
height: 15px;
border-radius: 3px;
background: var(--spark-color);
animation: fall var(--fall-time) linear forwards;
}
.toast {
position: absolute;
z-index: 40;
top: calc(68px + env(safe-area-inset-top));
right: 18px;
left: 18px;
padding: 10px 13px;
border: 1px solid rgba(255,255,255,.13);
border-radius: 12px;
opacity: 0;
color: #fff;
background: rgba(12, 9, 28, .9);
box-shadow: 0 12px 30px rgba(0,0,0,.25);
pointer-events: none;
transform: translateY(-10px);
transition: opacity .2s ease, transform .2s ease;
font-size: 11px;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.error { border-color: rgba(255,124,147,.5); color: #ffd9e0; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes feed-in { from { opacity: 0; transform: translateY(7px); } }
@keyframes meteor {
0% { transform: translate(0, 0) rotate(-35deg) scale(.35); opacity: 0; }
12% { opacity: 1; }
82% { transform: translate(var(--dx), var(--dy)) rotate(-35deg) scale(1); opacity: 1; }
100% { transform: translate(var(--dx), var(--dy)) rotate(-35deg) scale(1.8); opacity: 0; }
}
@keyframes banner {
0% { opacity: 0; transform: scale(.76) translateY(14px); }
16%, 78% { opacity: 1; transform: scale(1) translateY(0); }
100% { opacity: 0; transform: scale(1.03) translateY(-7px); }
}
@keyframes fall {
to { transform: translate3d(var(--drift), calc(100svh + 70px), 0) rotate(720deg); opacity: .15; }
}
@media (min-width: 700px) {
body { padding: 20px 0; }
.app { min-height: min(900px, calc(100svh - 40px)); border: 1px solid rgba(255,255,255,.08); border-radius: 28px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<main class="app">
<div class="meteor-layer" id="meteorLayer" aria-hidden="true"></div>
<div class="celebration-layer" id="celebrationLayer" aria-hidden="true"></div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<section class="setup-mask" id="setupMask" aria-live="polite">
<div class="setup-card">
<div class="setup-orbit">☄️</div>
<h2>准备真实联调数据</h2>
<p id="setupStatus">正在创建用户、语音房并设置 1,000,000 金币…</p>
<button class="retry-button" id="retryButton" type="button">重试</button>
</div>
</section>
<header class="topbar">
<div class="room-title">
<h1 id="roomName">星河幸运房</h1>
<div class="room-meta"><span class="live-dot"></span><span>真实房间</span><span>·</span><span id="roomID">准备中</span></div>
</div>
<button class="icon-button" id="resetButton" type="button" aria-label="重置测试数据" title="重置为 100 万金币"></button>
</header>
<section class="strategy-strip" aria-label="幸运礼物策略状态">
<span><strong id="strategyName">dynamic_v3</strong> · 实时结算</span>
<span id="rtpValue">RTP --</span>
<span id="ruleVersion">规则 --</span>
</section>
<section class="room-stage" aria-label="语音房麦位">
<div class="seats">
<article class="seat host" id="hostSeat">
<div class="avatar">🎙️</div>
<div class="seat-name" id="hostName">星河主播</div>
<div class="seat-id" id="hostID">UID --</div>
<span class="mic"></span>
</article>
<article class="seat sender" id="senderSeat">
<div class="avatar">🧑🏻</div>
<div class="seat-name" id="senderName">幸运玩家</div>
<div class="seat-id" id="senderID">UID --</div>
<span class="mic"></span>
</article>
<article class="seat empty" style="grid-column:2;grid-row:2"><div class="avatar"></div><div class="seat-name">空麦</div></article>
<article class="seat empty" style="grid-column:3;grid-row:2"><div class="avatar"></div><div class="seat-name">空麦</div></article>
<article class="seat empty" style="grid-column:4;grid-row:2"><div class="avatar"></div><div class="seat-name">空麦</div></article>
<article class="seat empty" style="grid-column:5;grid-row:2"><div class="avatar"></div><div class="seat-name">空麦</div></article>
<article class="seat empty" style="grid-column:1;grid-row:3"><div class="avatar"></div><div class="seat-name">空麦</div></article>
<article class="seat empty" style="grid-column:2;grid-row:3"><div class="avatar"></div><div class="seat-name">空麦</div></article>
<article class="seat empty" style="grid-column:4;grid-row:3"><div class="avatar"></div><div class="seat-name">空麦</div></article>
<article class="seat empty" style="grid-column:5;grid-row:3"><div class="avatar"></div><div class="seat-name">空麦</div></article>
</div>
<div class="activity-feed" id="activityFeed" aria-live="polite"></div>
</section>
<section class="drawer" aria-label="礼物面板">
<div class="drawer-head">
<span class="drawer-title">幸运礼物</span>
<span class="balance"><span class="coin"></span><span id="balance">--</span></span>
</div>
<article class="gift-card">
<div class="gift-visual">☄️</div>
<div class="gift-info">
<h2 id="giftName">幸运流星</h2>
<p><strong id="giftPrice">100</strong> 金币 / 个 · <span id="eligibility">资格检查中</span></p>
</div>
</article>
<div class="count-chips" id="countChips" aria-label="礼物数量">
<button class="count-chip active" type="button" data-count="1">× 1</button>
<button class="count-chip" type="button" data-count="9">× 9</button>
<button class="count-chip" type="button" data-count="99">× 99</button>
<button class="count-chip" type="button" data-count="999">× 999</button>
</div>
<div class="send-row">
<div class="send-meta">
<div class="target">送给 <strong id="targetName">星河主播</strong></div>
<div class="cost">本次 <span id="totalCost">100</span> 金币</div>
</div>
<button class="send-button" id="sendButton" type="button" disabled>送出幸运流星</button>
</div>
<details class="debug">
<summary>真实接口数据</summary>
<pre id="debugOutput">尚未送礼</pre>
</details>
</section>
</main>
<script>
(() => {
"use strict";
const state = {
fixture: null,
count: 1,
busy: false,
eligible: false,
lastResponse: null,
heartbeatTimer: null,
};
const elements = Object.fromEntries([
"setupMask", "setupStatus", "retryButton", "resetButton", "roomName", "roomID",
"strategyName", "rtpValue", "ruleVersion", "hostSeat", "hostName", "hostID",
"senderSeat", "senderName", "senderID", "activityFeed", "balance", "giftName",
"giftPrice", "eligibility", "countChips", "targetName", "totalCost", "sendButton",
"debugOutput", "meteorLayer", "celebrationLayer", "toast",
].map((id) => [id, document.getElementById(id)]));
const formatCoins = (value) => new Intl.NumberFormat("zh-CN").format(Number(value || 0));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function randomHex(bytes = 6) {
const data = new Uint8Array(bytes);
crypto.getRandomValues(data);
return [...data].map((item) => item.toString(16).padStart(2, "0")).join("");
}
function nextID(prefix) {
return `web-${prefix}-${Date.now()}-${randomHex()}`;
}
async function requestJSON(path, options = {}) {
const response = await fetch(path, options);
let payload;
try {
payload = await response.json();
} catch {
throw new Error(`${path} 返回了非 JSON 响应 (${response.status})`);
}
if (!response.ok || payload.code !== "OK") {
throw new Error(payload.message || `${path} 请求失败 (${response.status})`);
}
return payload.data;
}
async function gatewayJSON(path, { method = "GET", body, rawBody } = {}) {
if (!state.fixture) throw new Error("真实测试数据尚未准备完成");
const headers = {
"Accept": "application/json",
"Authorization": `Bearer ${state.fixture.sender_token}`,
"X-App-Code": state.fixture.app_code,
"X-App-Platform": "web",
"X-App-Language": "zh-CN",
};
const options = { method, headers };
if (body !== undefined || rawBody !== undefined) {
headers["Content-Type"] = "application/json";
options.body = rawBody !== undefined ? rawBody : JSON.stringify(body);
}
return requestJSON(path, options);
}
function showToast(message, error = false) {
elements.toast.textContent = message;
elements.toast.classList.toggle("error", error);
elements.toast.classList.add("show");
clearTimeout(showToast.timer);
showToast.timer = setTimeout(() => elements.toast.classList.remove("show"), 2600);
}
function addFeed(html) {
const item = document.createElement("div");
item.className = "feed-line";
item.innerHTML = html;
elements.activityFeed.append(item);
while (elements.activityFeed.children.length > 3) elements.activityFeed.firstElementChild.remove();
}
function safeFixtureForDisplay(fixture) {
// Token 仅保留在页面内存中用于调用真实 gateway调试面板永不渲染认证凭据或设备标识。
return {
app_code: fixture.app_code,
room_id: fixture.room_id,
gift_id: fixture.gift_id,
pool_id: fixture.pool_id,
rule_version: fixture.rule_version,
target_rtp_ppm: fixture.target_rtp_ppm,
initial_coins: fixture.initial_coins,
sender: { user_id: fixture.sender.user_id, display_user_id: fixture.sender.display_user_id, name: fixture.sender.name },
host: { user_id: fixture.host.user_id, display_user_id: fixture.host.display_user_id, name: fixture.host.name },
};
}
function renderFixture() {
const fixture = state.fixture;
elements.roomName.textContent = fixture.room_name;
elements.roomID.textContent = fixture.room_id;
elements.hostName.textContent = fixture.host.name;
elements.hostID.textContent = `UID ${fixture.host.display_user_id}`;
elements.senderName.textContent = fixture.sender.name;
elements.senderID.textContent = `UID ${fixture.sender.display_user_id}`;
elements.targetName.textContent = fixture.host.name;
elements.giftName.textContent = fixture.gift_name;
elements.giftPrice.textContent = formatCoins(fixture.gift_price);
elements.strategyName.textContent = "dynamic_v3";
elements.rtpValue.textContent = `RTP ${(fixture.target_rtp_ppm / 10000).toFixed(0)}%`;
elements.ruleVersion.textContent = `规则 v${fixture.rule_version}`;
elements.balance.textContent = formatCoins(fixture.initial_coins);
renderCost();
elements.debugOutput.textContent = JSON.stringify({ fixture: safeFixtureForDisplay(fixture) }, null, 2);
}
function renderCost() {
if (!state.fixture) return;
elements.totalCost.textContent = formatCoins(state.fixture.gift_price * state.count);
}
async function setupFixture(reset = false) {
if (state.busy) return;
state.busy = true;
state.eligible = false;
elements.setupMask.classList.remove("hidden");
elements.retryButton.classList.remove("visible");
elements.setupStatus.textContent = reset
? "正在恢复真实钱包到 1,000,000 金币并刷新房间在线状态…"
: "正在创建用户、语音房并设置 1,000,000 金币…";
elements.sendButton.disabled = true;
try {
state.fixture = await requestJSON("/__local/setup", { method: "POST" });
renderFixture();
await checkEligibility();
await refreshBalances();
addFeed(`<strong>${state.fixture.sender.name}</strong> 进入了房间`);
elements.setupMask.classList.add("hidden");
startHeartbeat();
} catch (error) {
elements.setupStatus.textContent = error.message;
elements.retryButton.classList.add("visible");
} finally {
state.busy = false;
elements.sendButton.disabled = !state.eligible;
}
}
async function checkEligibility() {
const fixture = state.fixture;
const data = await gatewayJSON("/api/v1/activities/lucky-gifts/check", {
method: "POST",
body: { room_id: fixture.room_id, gift_id: fixture.gift_id, pool_id: fixture.pool_id },
});
state.eligible = Boolean(data.enabled);
elements.eligibility.textContent = data.enabled ? "可参与" : `不可参与:${data.reason || "未启用"}`;
if (!data.enabled) throw new Error(`幸运礼物资格检查未通过:${data.reason || "unknown"}`);
}
function rawV2SendBody(commandID, comboSessionID) {
const fixture = state.fixture;
const targetID = String(fixture.host.user_id).trim();
if (!/^\d+$/.test(targetID)) throw new Error("目标内部用户 ID 不是有效整数");
// V2 HTTP 契约要求 target_user_id 为 JSON int64不能用字符串JS Number 会截断雪花 ID。
// 先序列化其余字段,再把已校验的十进制文本拼成无引号 JSON 数值,确保线上真实 ID 字节不变。
const base = JSON.stringify({
room_id: fixture.room_id,
command_id: commandID,
target_type: "user",
gift_id: fixture.gift_id,
gift_count: state.count,
pool_id: fixture.pool_id,
source: "local_voice_room_html",
combo_session_id: comboSessionID,
batch_seq: 1,
});
return `${base.slice(0, -1)},"target_user_id":${targetID}}`;
}
function assertV2Settlement(v2, commandID, comboSessionID) {
if (!v2 || Number(v2.api_version) < 2) throw new Error("服务端没有返回 V2 结算结果");
if (v2.operation_status !== "committed") throw new Error(`送礼未提交:${v2.operation_status || "unknown"}`);
if (v2.command_id !== commandID || v2.combo_session_id !== comboSessionID || Number(v2.batch_seq) !== 1) {
throw new Error("V2 结算标识与本次送礼不一致");
}
}
async function sendGift() {
if (state.busy || !state.fixture || !state.eligible) return;
state.busy = true;
elements.sendButton.disabled = true;
elements.sendButton.textContent = "结算中…";
const commandID = nextID("gift");
const comboSessionID = nextID("combo");
try {
await checkEligibility();
launchMeteor();
const response = await gatewayJSON("/api/v2/rooms/gift/send", {
method: "POST",
rawBody: rawV2SendBody(commandID, comboSessionID),
});
assertV2Settlement(response.v2, commandID, comboSessionID);
state.lastResponse = response;
elements.debugOutput.textContent = JSON.stringify({
fixture: safeFixtureForDisplay(state.fixture),
settlement: response,
}, null, 2);
// 幂等重放是同一财务事实,客户端必须保持静默,不能把旧结果再次渲染成新中奖。
if (!response.v2.idempotent_replay) {
await playSettlement(response.v2);
const hits = response.v2.lucky_hits || [];
const reward = hits.reduce((sum, hit) => sum + Number(hit.reward_coins || 0), 0);
addFeed(`<strong>${state.fixture.sender.name}</strong> 送出 ${state.fixture.gift_name} × ${state.count}${reward > 0 ? `,返奖 ${formatCoins(reward)}` : ""}`);
}
await pollAuthoritativeState();
showToast(`真实送礼已提交 · ${response.v2.settled_gift_count}`);
} catch (error) {
showToast(error.message, true);
addFeed(`送礼失败:${escapeHTML(error.message)}`);
} finally {
state.busy = false;
elements.sendButton.disabled = !state.eligible;
elements.sendButton.textContent = "送出幸运流星";
}
}
function escapeHTML(value) {
const node = document.createElement("span");
node.textContent = String(value);
return node.innerHTML;
}
function launchMeteor() {
const from = elements.senderSeat.getBoundingClientRect();
const to = elements.hostSeat.getBoundingClientRect();
const layer = elements.meteorLayer.getBoundingClientRect();
const meteor = document.createElement("div");
meteor.className = "meteor-flight";
const x = from.left + from.width / 2 - layer.left;
const y = from.top + from.height / 2 - layer.top;
meteor.style.left = `${x}px`;
meteor.style.top = `${y}px`;
meteor.style.setProperty("--dx", `${to.left + to.width / 2 - from.left - from.width / 2}px`);
meteor.style.setProperty("--dy", `${to.top + to.height / 2 - from.top - from.height / 2}px`);
elements.meteorLayer.append(meteor);
setTimeout(() => meteor.remove(), 900);
}
async function playSettlement(v2) {
await sleep(520);
const hits = [...(v2.lucky_hits || [])].sort((left, right) => Number(left.gift_index) - Number(right.gift_index));
if (hits.length === 0) {
await showWinBanner({ reward_coins: 0, multiplier_ppm: 0, gift_index: 0 }, "本次未中奖");
return;
}
// 单次 99/999 连送可能产生大量真实命中;页面保留完整响应,只把前 8 条逐次动画化,
// 其余命中以真实汇总展示,避免几十分钟的阻塞动画拖住下一笔交易。
for (const hit of hits.slice(0, 8)) await showWinBanner(hit);
if (hits.length > 8) {
const remaining = hits.slice(8);
const reward = remaining.reduce((sum, hit) => sum + Number(hit.reward_coins || 0), 0);
await showWinBanner({ reward_coins: reward, multiplier_ppm: 0, gift_index: `${remaining.length}` }, "其余命中汇总");
}
}
async function showWinBanner(hit, title = "幸运命中") {
const reward = Number(hit.reward_coins || 0);
const multiplier = Number(hit.multiplier_ppm || 0) / 1_000_000;
const banner = document.createElement("div");
const big = Number(hit.multiplier_ppm || 0) >= 5_000_000 || reward >= 5_000;
banner.className = `win-banner${big ? " big" : ""}`;
banner.innerHTML = `
<div>
<div class="win-kicker">${escapeHTML(title)}</div>
<div class="win-amount">${reward > 0 ? `+ ${formatCoins(reward)} 金币` : "擦肩而过"}</div>
<div class="win-detail">${multiplier > 0 ? `${multiplier.toFixed(multiplier % 1 ? 1 : 0)}× · ` : ""}${escapeHTML(hit.gift_index || 1)} 个礼物</div>
</div>`;
elements.celebrationLayer.append(banner);
requestAnimationFrame(() => banner.classList.add("show"));
if (big) rainSparks();
await sleep(1850);
banner.remove();
}
function rainSparks() {
const colors = ["#ffd86f", "#ff70bd", "#8f79ff", "#69dfff"];
for (let index = 0; index < 34; index += 1) {
const spark = document.createElement("i");
spark.className = "spark";
spark.style.left = `${Math.random() * 100}%`;
spark.style.setProperty("--spark-color", colors[index % colors.length]);
spark.style.setProperty("--drift", `${(Math.random() - .5) * 160}px`);
spark.style.setProperty("--fall-time", `${1.2 + Math.random() * 1.2}s`);
spark.style.animationDelay = `${Math.random() * .35}s`;
elements.celebrationLayer.append(spark);
setTimeout(() => spark.remove(), 2900);
}
}
async function refreshBalances() {
const data = await gatewayJSON("/api/v1/wallet/me/balances?asset_type=COIN");
const coin = (data.balances || []).find((item) => String(item.asset_type).toUpperCase() === "COIN");
elements.balance.textContent = formatCoins(coin ? coin.available_amount : 0);
}
async function refreshLocalState() {
const data = await requestJSON("/__local/state");
elements.balance.textContent = formatCoins(data.sender_balance);
return data;
}
async function pollAuthoritativeState() {
// 返奖和扣费都以钱包 owner 的查询结果为准;页面不拿本地“余额 ± 奖励”推算财务事实。
for (const delay of [0, 500, 1500, 3000]) {
if (delay) await sleep(delay);
try {
await Promise.all([refreshBalances(), refreshLocalState()]);
} catch (error) {
if (delay === 3000) throw error;
}
}
}
function startHeartbeat() {
clearInterval(state.heartbeatTimer);
state.heartbeatTimer = setInterval(async () => {
try {
await requestJSON("/__local/heartbeat", { method: "POST" });
} catch (error) {
showToast(`房间心跳失败:${error.message}`, true);
}
}, 30_000);
}
elements.countChips.addEventListener("click", (event) => {
const button = event.target.closest("button[data-count]");
if (!button || state.busy) return;
state.count = Number(button.dataset.count);
for (const chip of elements.countChips.querySelectorAll("button")) chip.classList.toggle("active", chip === button);
renderCost();
});
elements.sendButton.addEventListener("click", sendGift);
elements.retryButton.addEventListener("click", () => setupFixture(Boolean(state.fixture)));
elements.resetButton.addEventListener("click", () => setupFixture(true));
setupFixture(false);
})();
</script>
</body>
</html>