30a17f7d99
onBetClosed показывал бинарно: всё, что не payout_done, — «ПРОИГРАЛ (house_won)» красным. Ничья (деньги вернулись игроку) выглядела как проигрыш. Теперь три исхода + нейтральная заглушка: - payout_done → ВЫИГРАЛ (ok) - house_won → ПРОИГРАЛ (err) - refunded → ВОЗВРАТ (ничья, ставка возвращена) (info) - иное → РАССЧИТАНА (<statusName>) (info), не валится в проигрыш API.md: пример bet_closed со status 3 + описание refunded. Проверено: node --check инлайн-JS OK, образ smart/test-front:5, раздача наружу.
440 lines
19 KiB
HTML
440 lines
19 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8"/>
|
||
<title>Binary Rocket — техтест экономики через HTTP-шлюз (фантики, без Phantom)</title>
|
||
<style>
|
||
body{font-family:monospace;background:#111;color:#eee;margin:20px;font-size:14px}
|
||
h1{font-size:16px}
|
||
.row{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0;align-items:center}
|
||
button{background:#333;color:#fff;border:1px solid #555;padding:6px 10px;cursor:pointer;font-family:monospace}
|
||
button:disabled{opacity:.4;cursor:not-allowed}
|
||
input,select{background:#222;color:#eee;border:1px solid #555;padding:5px;font-family:monospace}
|
||
input::placeholder{color:#555}
|
||
label{margin-right:2px}
|
||
#log{background:#000;border:1px solid #444;padding:10px;height:320px;overflow:auto;white-space:pre-wrap;font-size:12px;line-height:1.4}
|
||
.ok{color:#7f7} .err{color:#f77} .info{color:#88f}
|
||
fieldset{border:1px solid #444;margin:10px 0;padding:10px}
|
||
legend{font-size:13px;color:#aaa}
|
||
.bal{font-size:15px;font-weight:bold}
|
||
.hint{color:#666;font-size:12px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>Binary Rocket :: техтест через HTTP-шлюз (фантики, без Phantom)</h1>
|
||
|
||
<fieldset>
|
||
<legend>Цена SOL (WebSocket)</legend>
|
||
<div class="row">
|
||
<span style="font-size:22px;font-weight:bold">SOL: <span id="solPrice">—</span></span>
|
||
<span class="hint" id="solPriceTs"></span>
|
||
</div>
|
||
<div class="row">
|
||
<span>Статус: <span id="wsStatus" class="hint">отключено</span></span>
|
||
<span class="hint">сообщений: <span id="wsMsgCount">0</span></span>
|
||
<button id="wsToggle" onclick="wsToggle()">Подключить</button>
|
||
</div>
|
||
<div class="row">
|
||
<label>WS URL</label><input id="wsUrl" size="46" placeholder="(пусто — вычислить из gwBase)"/>
|
||
</div>
|
||
</fieldset>
|
||
|
||
<fieldset>
|
||
<legend>Конфигурация шлюза</legend>
|
||
<div class="row">
|
||
<label>Шлюз (API)</label><input id="gwBase" size="40" value="https://testfront.binom.pw/api"/>
|
||
<span class="hint">если пусто — фронт выведет и использует свой адрес (location.origin)</span>
|
||
</div>
|
||
<div class="row">
|
||
<label>Адрес</label><input id="addr" size="46" value="6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6"/>
|
||
<button onclick="fillPreset('admin')">admin</button>
|
||
<button onclick="fillPreset('bettor-1')">bettor-1</button>
|
||
<button onclick="fillPreset('bettor-2')">bettor-2</button>
|
||
</div>
|
||
<div class="hint">admin — источник фантиков; bettor-N — custodial-кошельки, зарегистрированные в keystore шлюза</div>
|
||
</fieldset>
|
||
|
||
<fieldset>
|
||
<legend>Состояние / баланс</legend>
|
||
<div class="row">
|
||
<button onclick="gwState()">Обновить state</button>
|
||
<button onclick="gwBalance()">Проверить баланс</button>
|
||
<button onclick="copyLog()">Копировать лог</button>
|
||
<button onclick="log.clear()">Очистить лог</button>
|
||
</div>
|
||
<div class="row bal">
|
||
<span>Фантики: <span id="balTok">—</span></span>
|
||
<span style="margin-left:18px">SOL: <span id="balSol">—</span></span>
|
||
</div>
|
||
<div class="hint">баланс показывается для адреса из поля «Адрес»</div>
|
||
<div id="stateInfo" class="info" style="margin-top:6px">Состояние ещё не прочитано.</div>
|
||
</fieldset>
|
||
|
||
<fieldset>
|
||
<legend>Кошелёк и пополнение (тест: POST /wallet, GET /faucet)</legend>
|
||
<div class="row">
|
||
<button onclick="gwWallet()">Создать кошелёк</button>
|
||
<button onclick="gwTopUp()">Пополнить кошелёк</button>
|
||
<span class="hint">Пополнение — только на тестовом стенде (на релизе шлюз ответит 403)</span>
|
||
</div>
|
||
<div class="row">
|
||
<label>Адрес кошелька</label><input id="walletAddr" size="46" readonly placeholder="(нажмите «Создать кошелёк»)"/>
|
||
<button onclick="useWalletAddr()">В поле «Адрес»</button>
|
||
</div>
|
||
<div class="hint">«Создать кошелёк» ничего не наливает: сначала создайте, затем нажмите «Пополнить кошелёк», потом ставьте.</div>
|
||
</fieldset>
|
||
|
||
<fieldset>
|
||
<legend>Ставка (POST /bet, без Phantom)</legend>
|
||
<div class="row">
|
||
<label>Сумма (фантики)</label><input id="betAmt" size="14" value="1000"/>
|
||
<label>Сторона</label>
|
||
<select id="betSide">
|
||
<option value="UP">UP (вверх)</option>
|
||
<option value="DOWN">DOWN (вниз)</option>
|
||
</select>
|
||
<button onclick="gwBet()">Сделать ставку</button>
|
||
</div>
|
||
<div class="hint">сумма ×1e6 → units (decimals=6). Адрес обязателен: сначала «Создать кошелёк» → «Пополнить кошелёк» (на тесте), затем ставка.</div>
|
||
<div class="hint" id="betLimits">лимиты ставок неизвестны (обновите state)</div>
|
||
</fieldset>
|
||
|
||
<fieldset>
|
||
<legend>Ставка: ход игры (сервер закрывает сам)</legend>
|
||
<div class="row">
|
||
<span>№ ставки: <b id="liveBetId">—</b></span>
|
||
<span>состояние: <b id="liveBetState">нет ставки</b></span>
|
||
</div>
|
||
<div class="row">
|
||
<span>вход: <b id="liveBetEntry">—</b></span>
|
||
<span>выход: <b id="liveBetExit">—</b></span>
|
||
</div>
|
||
<div class="hint">Ставку открывает кнопка «Сделать ставку» (HTTP). Итог приходит сам по WebSocket:
|
||
<code>bet_open</code> — ставка принята, <code>bet_closed</code> — рассчитана (примерно через 15 с).
|
||
Пока ставка открыта, вторую на этот адрес сервер не примет.</div>
|
||
</fieldset>
|
||
|
||
<fieldset>
|
||
<legend>Лог</legend>
|
||
<div id="log"></div>
|
||
</fieldset>
|
||
|
||
<script>
|
||
// ---------- tech-test front: Binary Rocket economics via HTTP gateway (без Phantom) ----------
|
||
const log = (() => {
|
||
const el = () => document.getElementById('log');
|
||
const line = (cls, text) => { const e=el(); const d=document.createElement('div'); d.className=cls; d.textContent='['+new Date().toLocaleTimeString()+'] '+text; e.appendChild(d); e.scrollTop=e.scrollHeight; };
|
||
return { ok:(t)=>line('ok',t), err:(t)=>line('err',t), info:(t)=>line('info',t), clear:()=>el().textContent='' };
|
||
})();
|
||
|
||
const PRESETS = {
|
||
'admin': 'Huha5S9ySxHC9u7RXmeQbt8GZh1VXGzfjVPGdsYV2tKK',
|
||
'bettor-1': '6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6',
|
||
'bettor-2': '6v2HXsowRnwrX82K7kCAKzkCpFA4gLqk1DAmRQvA96WA'
|
||
};
|
||
function fillPreset(name){
|
||
document.getElementById('addr').value = PRESETS[name] || '';
|
||
log.info('Пресет "'+name+'" → '+document.getElementById('addr').value);
|
||
}
|
||
|
||
const ADDR = () => document.getElementById('addr').value.trim();
|
||
|
||
// gwBase: если пусто — фронт выводит и использует свой адрес (location.origin)
|
||
function gwBase(){
|
||
const v = document.getElementById('gwBase').value.trim().replace(/\/+$/,'');
|
||
return v || location.origin;
|
||
}
|
||
|
||
async function gwFetch(path, body){
|
||
const url = gwBase() + path;
|
||
const opts = { method: body ? 'POST' : 'GET', headers: {'Content-Type':'application/json'} };
|
||
if(body) opts.body = JSON.stringify(body);
|
||
const r = await fetch(url, opts);
|
||
const text = await r.text();
|
||
let j = null; try{ j = JSON.parse(text); }catch(e){}
|
||
if(!r.ok) throw new Error('HTTP '+r.status+' '+(text||r.statusText));
|
||
return j;
|
||
}
|
||
|
||
// фантики (до 6 знаков) → токен-юниты (×1e6), через строку — без float-погрешности
|
||
function humanToUnits(human, dec){
|
||
dec = dec || 6;
|
||
const s = String(human==null?'':human).trim().replace(',', '.');
|
||
if(s===''||!/^\d*(\.\d*)?$/.test(s)) return null;
|
||
const dot = s.indexOf('.');
|
||
const intPart = dot===-1 ? s : s.slice(0,dot);
|
||
const frac = dot===-1 ? '' : s.slice(dot+1);
|
||
const digits = (intPart||'0') + frac.padEnd(dec,'0').slice(0,dec);
|
||
const clean = digits.replace(/^0+(?=\d)/,'');
|
||
return /^\d+$/.test(clean) ? BigInt(clean) : null;
|
||
}
|
||
|
||
let lastState = null;
|
||
async function gwState(){
|
||
try{
|
||
const j = await gwFetch('/state');
|
||
lastState = j;
|
||
document.getElementById('stateInfo').textContent =
|
||
'counter='+j.counter+
|
||
' | min='+j.minAmountHuman+' max='+j.maxAmountHuman+' фантик'+
|
||
' | x='+(Number(j.multiplierBps)/100)+
|
||
' | expiry='+j.expirySeconds+'s'+
|
||
' | paused='+j.paused+
|
||
' | vault='+j.vaultBalanceHuman+' фантик'+
|
||
' | admin='+j.admin+
|
||
' | relayer='+j.relayer;
|
||
document.getElementById('betLimits').textContent = 'лимиты шлюза: '+j.minAmountHuman+'–'+j.maxAmountHuman+' фантик';
|
||
log.ok('State: counter='+j.counter+', vault='+j.vaultBalanceHuman+' фантик, min='+j.minAmountHuman+', max='+j.maxAmountHuman);
|
||
}catch(e){ log.err('state: '+e.message); }
|
||
}
|
||
|
||
async function gwBalance(){
|
||
try{
|
||
const a = ADDR();
|
||
if(!a){ log.err('balance: введите адрес в поле «Адрес»'); return; }
|
||
const j = await gwFetch('/balance/'+encodeURIComponent(a));
|
||
document.getElementById('balTok').textContent = j.tokenHuman;
|
||
document.getElementById('balSol').textContent = Number(j.sol).toFixed(6)+' SOL';
|
||
log.ok('Баланс '+a+': фантики='+j.tokenHuman+', SOL='+j.sol+', tokenUnits='+j.tokenUnits);
|
||
}catch(e){ log.err('balance: '+e.message); }
|
||
}
|
||
|
||
// ---- Кошелёк и пополнение (тест) ----
|
||
async function gwWallet(){
|
||
try{
|
||
const j = await gwFetch('/wallet', {});
|
||
document.getElementById('walletAddr').value = j.address || '';
|
||
log.ok('Кошелёк создан: '+j.address+' (денег на нём пока нет — нажмите «Пополнить кошелёк»)');
|
||
}catch(e){ log.err('wallet: '+e.message); }
|
||
}
|
||
|
||
async function gwTopUp(){
|
||
try{
|
||
const w = document.getElementById('walletAddr').value.trim();
|
||
const a = w || ADDR();
|
||
if(!a){ log.err('faucet: нет адреса — создайте кошелёк (или впишите адрес в поле «Адрес»)'); return; }
|
||
if(!w) document.getElementById('walletAddr').value = a;
|
||
const j = await gwFetch('/faucet/'+encodeURIComponent(a));
|
||
document.getElementById('balTok').textContent = j.tokenHuman;
|
||
document.getElementById('balSol').textContent = Number(j.sol).toFixed(6)+' SOL';
|
||
log.ok('Пополнено '+a+': фантики='+j.tokenHuman+', SOL='+j.sol);
|
||
}catch(e){
|
||
log.err('faucet: '+e.message);
|
||
if(String(e.message).indexOf('403')>=0) log.info('403 = пополнение выключено (режим release или сеть не тестовая). Это ожидаемо на боевом стенде.');
|
||
}
|
||
}
|
||
|
||
function useWalletAddr(){
|
||
const w = document.getElementById('walletAddr').value.trim();
|
||
if(!w){ log.err('в поле нет адреса кошелька — сначала «Создать кошелёк»'); return; }
|
||
document.getElementById('addr').value = w;
|
||
log.info('Адрес «'+w+'» подставлен в поле «Адрес» (для ставки и баланса)');
|
||
}
|
||
|
||
let liveBetId = '';
|
||
function setLiveBet(field, value){
|
||
const el = document.getElementById(field);
|
||
if(el) el.textContent = value;
|
||
}
|
||
async function gwBet(){
|
||
try{
|
||
const side = document.getElementById('betSide').value;
|
||
const units = humanToUnits(document.getElementById('betAmt').value);
|
||
if(units===null){ log.err('bet: некорректная сумма (фантики, до 6 знаков, напр 1000 или 1.5)'); return; }
|
||
if(units===0n){ log.err('bet: сумма должна быть больше нуля'); return; }
|
||
if(lastState){
|
||
const min = BigInt(lastState.minAmount), max = BigInt(lastState.maxAmount);
|
||
if(units<min||units>max) log.info('внимание: сумма вне лимитов '+lastState.minAmountHuman+'–'+lastState.maxAmountHuman+' (шлюз отклонит)');
|
||
}
|
||
const address = ADDR();
|
||
log.info('POST /bet side='+side+', amountUnits='+units.toString()+(address?' (address='+address+')':' (адрес пуст — шлюз сгенерит новый кошелёк)'));
|
||
const j = await gwFetch('/bet', {side:side, amountUnits:units.toString(), address:address});
|
||
liveBetId = String(j.id);
|
||
setLiveBet('liveBetId', liveBetId);
|
||
setLiveBet('liveBetState', 'открыта, ждём закрытия (~15 с)');
|
||
setLiveBet('liveBetEntry', String(j.entryPrice));
|
||
setLiveBet('liveBetExit', '—');
|
||
log.ok('Ставка принята: id='+j.id+', bettor='+j.bettor+', entryPrice='+j.entryPrice+(j.tx?' tx='+j.tx:''));
|
||
if(!address) log.info('кошелёк шлюза: '+j.bettor+' (скопируйте в поле «Адрес» для дальнейших ставок)');
|
||
}catch(e){ log.err('bet: '+e.message); }
|
||
}
|
||
|
||
/** Событие WS: ставка открыта. */
|
||
function onBetOpen(m){
|
||
setLiveBet('liveBetId', String(m.id));
|
||
setLiveBet('liveBetState', 'открыта ('+m.side+'), ждём закрытия');
|
||
setLiveBet('liveBetEntry', String(m.entryPrice));
|
||
setLiveBet('liveBetExit', '—');
|
||
log.info('bet_open: #'+m.id+' '+m.side+', сумма='+m.amountUnits+', вход='+m.entryPrice);
|
||
}
|
||
|
||
/** Событие WS: ставка рассчитана — сервер закрыл её сам. */
|
||
function onBetClosed(m){
|
||
setLiveBet('liveBetId', String(m.id));
|
||
setLiveBet('liveBetExit', String(m.exitPrice));
|
||
const sn = m.statusName;
|
||
if(sn === 'payout_done'){
|
||
setLiveBet('liveBetState', 'ВЫИГРАЛ (payout_done)');
|
||
log.ok('bet_closed: #'+m.id+', выход='+m.exitPrice+', ВЫИГРАЛ');
|
||
} else if(sn === 'house_won'){
|
||
setLiveBet('liveBetState', 'ПРОИГРАЛ (house_won)');
|
||
log.err('bet_closed: #'+m.id+', выход='+m.exitPrice+', ПРОИГРАЛ');
|
||
} else if(sn === 'refunded'){
|
||
setLiveBet('liveBetState', 'ВОЗВРАТ (ничья, ставка возвращена)');
|
||
log.info('bet_closed: #'+m.id+', выход='+m.exitPrice+', НИЧЬЯ — залог возвращён');
|
||
} else {
|
||
setLiveBet('liveBetState', 'РАССЧИТАНА ('+(sn||'?')+')');
|
||
log.info('bet_closed: #'+m.id+', выход='+m.exitPrice+', статус='+(sn||'?'));
|
||
}
|
||
}
|
||
|
||
function copyLog(){ const t=document.getElementById('log').innerText; navigator.clipboard?.writeText(t).then(()=>log.ok('лог скопирован')).catch(()=>{}); }
|
||
|
||
// ---------- WebSocket: живая цена SOL ----------
|
||
let ws = null;
|
||
let wsReconnectTimer = null;
|
||
let wsBackoffMs = 3000;
|
||
const WS_BACKOFF_MAX = 15000;
|
||
let wsIntentClosed = false;
|
||
let wsMsgCount = 0;
|
||
let solPriceTsMs = 0;
|
||
|
||
function setWsStatus(text, cls){
|
||
const e = document.getElementById('wsStatus');
|
||
if(!e) return;
|
||
e.textContent = text;
|
||
e.className = cls || 'hint';
|
||
}
|
||
|
||
function syncWsToggleButton(){
|
||
const btn = document.getElementById('wsToggle');
|
||
if(!btn) return;
|
||
const isUp = ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING);
|
||
btn.textContent = isUp ? 'Отключить' : 'Подключить';
|
||
}
|
||
|
||
function formatSolPrice(p){
|
||
const s = String(p);
|
||
const dot = s.indexOf('.');
|
||
if(dot === -1) return s;
|
||
const intPart = s.slice(0, dot);
|
||
const frac = s.slice(dot + 1);
|
||
if(frac.length > 2) return (intPart || '0') + '.' + frac.slice(0, 2);
|
||
return s;
|
||
}
|
||
|
||
function updateSolPriceMeta(){
|
||
const e = document.getElementById('solPriceTs');
|
||
if(!e) return;
|
||
if(!solPriceTsMs){ e.textContent = ''; return; }
|
||
const sec = Math.max(0, Math.floor((Date.now() - solPriceTsMs) / 1000));
|
||
const t = new Date(solPriceTsMs).toLocaleTimeString();
|
||
e.textContent = ' ('+t+', '+sec+'с назад)';
|
||
}
|
||
|
||
function wsUrl(){
|
||
const v = document.getElementById('wsUrl').value.trim();
|
||
if(v) return v;
|
||
const base = gwBase();
|
||
let u;
|
||
if(base.startsWith('https://')) u = 'wss://' + base.slice('https://'.length);
|
||
else if(base.startsWith('http://')) u = 'ws://' + base.slice('http://'.length);
|
||
else u = base;
|
||
return u.replace(/\/+$/, '') + '/ws';
|
||
}
|
||
|
||
function connectWs(){
|
||
if(ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
|
||
wsIntentClosed = false;
|
||
const url = wsUrl();
|
||
document.getElementById('wsUrl').placeholder = url;
|
||
setWsStatus('подключение…', 'hint');
|
||
try{
|
||
ws = new WebSocket(url);
|
||
}catch(e){
|
||
log.err('ws: не удалось открыть '+url+' — '+e.message);
|
||
setWsStatus('ошибка', 'err');
|
||
scheduleWsReconnect();
|
||
syncWsToggleButton();
|
||
return;
|
||
}
|
||
ws.onopen = () => {
|
||
wsBackoffMs = 3000;
|
||
setWsStatus('подключено', 'ok');
|
||
syncWsToggleButton();
|
||
log.ok('ws: connected '+url);
|
||
};
|
||
ws.onmessage = (ev) => {
|
||
let m;
|
||
try{ m = JSON.parse(ev.data); }catch(e){ return; }
|
||
if(!m || !m.type) return;
|
||
// Клиентские события жизненного цикла ставки — сервер закрывает ставку сам.
|
||
if(m.type === 'bet_open'){ onBetOpen(m); return; }
|
||
if(m.type === 'bet_closed'){ onBetClosed(m); return; }
|
||
if(m.type !== 'price') return;
|
||
wsMsgCount++;
|
||
document.getElementById('wsMsgCount').textContent = String(wsMsgCount);
|
||
const fullPrice = String(m.price);
|
||
const short = formatSolPrice(fullPrice);
|
||
const sp = document.getElementById('solPrice');
|
||
sp.textContent = short;
|
||
sp.title = fullPrice;
|
||
solPriceTsMs = Number(m.ts) || Date.now();
|
||
updateSolPriceMeta();
|
||
if(wsMsgCount === 1 || wsMsgCount % 50 === 0){
|
||
log.info('ws: price='+fullPrice+' (symbol='+m.symbol+')');
|
||
}
|
||
};
|
||
ws.onerror = () => {
|
||
setWsStatus('ошибка', 'err');
|
||
};
|
||
ws.onclose = () => {
|
||
ws = null;
|
||
syncWsToggleButton();
|
||
if(wsIntentClosed){
|
||
setWsStatus('отключено', 'hint');
|
||
return;
|
||
}
|
||
setWsStatus('переподключение…', 'hint');
|
||
scheduleWsReconnect();
|
||
};
|
||
}
|
||
|
||
function scheduleWsReconnect(){
|
||
if(wsReconnectTimer) return;
|
||
const delay = wsBackoffMs;
|
||
wsReconnectTimer = setTimeout(() => {
|
||
wsReconnectTimer = null;
|
||
connectWs();
|
||
}, delay);
|
||
wsBackoffMs = Math.min(WS_BACKOFF_MAX, wsBackoffMs * 2);
|
||
}
|
||
|
||
function disconnectWs(){
|
||
wsIntentClosed = true;
|
||
if(wsReconnectTimer){ clearTimeout(wsReconnectTimer); wsReconnectTimer = null; }
|
||
if(ws){
|
||
try{ ws.close(); }catch(e){}
|
||
}
|
||
}
|
||
|
||
function wsToggle(){
|
||
const isUp = ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING);
|
||
if(isUp) disconnectWs(); else connectWs();
|
||
}
|
||
|
||
(function init(){
|
||
const el = document.getElementById('gwBase');
|
||
if(!el.value.trim()){
|
||
el.placeholder = location.origin;
|
||
log.info('Шлюз (gwBase) не указан — используется адрес страницы: '+location.origin);
|
||
}
|
||
gwState();
|
||
gwBalance();
|
||
connectWs();
|
||
setInterval(updateSolPriceMeta, 1000);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|