Files
Caffeine 30a17f7d99 feat(ui): третий исход в UI — возврат залога при ничьей
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, раздача наружу.
2026-09-14 02:09:15 +03:00

440 lines
19 KiB
HTML
Raw Permalink 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="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>