6 Commits
2 ... main

Author SHA1 Message Date
Caffeine 07e00f106c fix(api): рабочий рецепт — /api в базе обязателен для POST
Найдено исполнением рецепта из самого файла: `const BASE = 'https://testfront.binom.pw'`
→ `POST /wallet` возвращал 405 Method Not Allowed. GET-ручки терпят отсутствие
префикса (`/state` == `/api/state`), а POST-ручки — нет. Внешний разработчик
спотыкался на первой же строке примера.

Что исправлено:
- база в примере: `https://testfront.binom.pw/api` + предупреждение про /api
- POST /wallet в примере: добавлены заголовок Content-Type и тело `{}` (без них 405)
- WS-адрес в примере: явный `wss://` вместо `BASE.replace('https','wss')`,
  который после смены базы дал бы кривой `wss://testfront.binom.pw/api.replace…`
- amountUnits в примере — строкой (поддержаны оба вида, число тоже работает)

Проверено: рецепт, выдранный из файла регуляркой и запущенный как есть, отдаёт
курс, `bet_open` и `bet_closed`. WS принимает подключение с чужого Origin
(проверено тремя вариантами заголовка).
2026-09-14 02:32:32 +03:00
Caffeine b8c040dedc docs: привести README и API.md в соответствие с живым стендом
Оба файла описывали состояние, которого больше нет, и содержали ошибки,
из-за которых внешний разработчик не собрал бы рабочий фронт:

- README описывал Phantom, swap ввод/вывод (POST /api/pump/swap), STUB-ключи
  и Фазу 1 — ничего этого в index.html нет. Указан нерабочий API base
  (smart-updown.binom.pw вместо testfront.binom.pw) и адрес контракта FMM….
  «Ставка через поллинг» — поллинга нет, события по WS.
- API.md держал раздел «часть ставок зависает без исхода» со статусом
  «не исправлено» — баг закрыт 14.09.2026 в контракте. По этой доке писали бы
  обходной код под проблему, которой нет.

README переписан под фактический index.html: HTTP-шлюз без Phantom,
сценарий кошелёк→фаусет→ставка→WS-исход, три исхода, правило 409.
Секция API.md заменена на «Ничья при exitPrice == entryPrice» (штатный
исход, доля ~трети), историческая сноска на SEC-9 сохранена.

Проверено: полный сценарий снаружи по новой доке проходит
(кошелёк → фаусет 10000 → ставка id=44 → исход по балансу).
2026-09-14 02:24:53 +03:00
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
assistant d9b1cc1ed8 docs(api): контракт API для клиента — HTTP + WS-события
API.md: полное описание ручек, трёх WS-событий (price/bet_open/bet_closed),
полей с масштабами, ошибок (409 = уже есть открытая ставка), авто-закрытия.
Все значения сверены с живым стендом.

README: убрано устаревшее 'поллинг до SETTLED' — поллинга нет, события идут по WS.

Отдельно задокументирован SEC-9: ~33.6% ставок (замер на 580 мин) зависают
из-за exit==entry и вечного ретрая.
2026-09-14 00:20:12 +03:00
Caffeine f9daee67bf feat(front): убрана мёртвая кнопка /close, события ставки по WebSocket
- блок «Закрытие (POST /close)» -> «Ставка: ход игры»: № ставки, состояние, вход/выход
- ручка /close удалена на сервере (404) -> кнопка и gwClose() убраны
- ws.onmessage разбирает bet_open / bet_closed: ставка показывается и
  рассчитывается сама, сервер закрывает её без участия клиента
2026-09-13 18:49:32 +03:00
Caffeine 707668b178 feat(front): отдельный блок «Кошелёк и пополнение» на техтест-пульте
- кнопки «Создать кошелёк» (POST /wallet) и «Пополнить кошелёк» (GET /faucet)
- поле адреса кошелька + подстановка в поле «Адрес»
- подсказка про 403 в режиме release
- ставка: пояснение, что адрес обязателен и деньги выдаёт отдельная кнопка
2026-09-13 13:38:38 +03:00
3 changed files with 308 additions and 49 deletions
+178
View File
@@ -0,0 +1,178 @@
# API тестового стенда — контракт для клиента
Всё, что нужно, чтобы подключиться к шлюзу игры «ставка на направление цены SOL»
и получать события в реальном времени. Источник — код шлюза
(`smart-updown-token/relayer/src/gateway.ts`, `settle.ts`).
**База:** `https://testfront.binom.pw/api`
CORS открыт (`Access-Control-Allow-Origin: *`) — можно дёргать прямо из браузера.
> ⚠️ **`/api` в базе обязателен.** GET-ручки (`/state`, `/price`, `/balance`, `/faucet`)
> отвечают и без префикса — `…/state` и `…/api/state` одно и то же. Но **POST-ручки
> (`/wallet`, `/bet`) без `/api` не работают**: `POST …/wallet` → `405 Method Not Allowed`.
> Держите `/api` в базе, и всё будет единообразно.
**Стенд тестовый** (`SMART_MODE=test`): фантики фейковые, SOL из фаусета, контракт наш.
---
## Короткий рецепт: «поставить и получить события»
```js
const BASE = 'https://testfront.binom.pw/api'; // /api обязателен, см. выше
const H = { 'Content-Type': 'application/json' };
// 1. Кошелёк + деньги (только test-mode)
const { address } = await (await fetch(`${BASE}/wallet`, { method: 'POST', headers: H, body: '{}' })).json();
await fetch(`${BASE}/faucet/${address}`); // наливает 5 SOL + 10 000 фантиков
// 2. Подписка на события
const ws = new WebSocket('wss://testfront.binom.pw/api/ws'); // только wss: страница по HTTPS
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.type === 'price') console.log('курс ', m.price); // "101.02"
if (m.type === 'bet_open') console.log('ставка открыта', m.id, m.side, m.entryPrice);
if (m.type === 'bet_closed') console.log('ставка закрыта', m.id, m.exitPrice, m.statusName);
};
// 3. Ставка. ВНИМАНИЕ: поле называется amountUnits, НЕ amount!
await fetch(`${BASE}/bet`, {
method: 'POST',
headers: H,
body: JSON.stringify({ address, side: 'UP', amountUnits: '500000000' }), // 500 фантиков
});
// Закрывать не нужно — шлюз закроет сам по таймеру (см. «Авто-закрытие»).
```
---
## HTTP-ручки
### `GET /state` — состояние контракта
```json
{
"counter": "3", "nextBetId": "3",
"minAmount": "100000000", "maxAmount": "10000000000",
"minAmountHuman": "100.000000", "maxAmountHuman": "10000.000000",
"multiplierBps": "13000", "expirySeconds": "15", "paused": 0,
"admin": "E7Hg55…", "relayer": "E7Hg55…",
"vaultBalance": "26000000000", "vaultBalanceHuman": "26000.000000"
}
```
`multiplierBps: 13000` = выплата **1.3×** при выигрыше. `expirySeconds: 15` = окно ставки.
`paused: 1` = приём ставок остановлен.
### `POST /wallet` — создать кошелёк (только test-mode)
`{}` → `{"address": "DxfxzWYF…"}`. Денег **не** наливает — отдельно через `/faucet`.
### `GET /faucet/{address}` — налить SOL + фантиков (только test-mode)
Работает на **произвольный** адрес (Phantom и т.п.):
`{"address":"…","sol":5,"solLamports":5000000000,"tokenUnits":"10000000000","tokenHuman":"10000.000000"}`.
Вне test-mode → **403**.
### `GET /balance/{address}` — балансы
`{"sol":5,"solLamports":5000000000,"tokenUnits":"10000000000","tokenHuman":"10000.000000"}`
### `POST /bet` — поставить
Тело: `{"address": "…", "side": "UP" | "DOWN", "amountUnits": 500000000}`
* `address` — обязателен, получен из `/wallet` (или любой пополненный через `/faucet`).
* `side` — регистр не важен (`up`/`UP`).
* **`amountUnits` — целое в базовых единицах токена** (у нашего минта `decimals=6`:
`500000000` = 500 фантиков). **Минимум `100000000`** (100 фантиков).
⚠️ Поле `amount` вместо `amountUnits` → `400 {"error":"amountUnits must be a positive integer (token base units)"}`.
Ответ `200`: `{"tx":"4y2HYuu…","id":"0","bettor":"…","entryPrice":10112000000}`
**Закрывать не нужно** — шлюз закроет сам по экспирации.
Ошибки:
| Код | Когда |
|---|---|
| `400` | нет/неверный `amountUnits`, неизвестный `side`, сумма вне min/max |
| `403` | ставки приостановлены (`paused=1`) |
| `409` | **у адреса уже есть открытая ставка** — `{"error":"you already have an open bet: close it before placing a new one","openBetId":"0"}` |
| `409` | контракт не инициализирован |
| `500` | RPC недоступен |
> Правило: **одна открытая ставка на адрес.** Проверка идёт запросом в цепочку, не в память
> процесса — обойти перезапуском клиента нельзя.
### `GET /price` — текущая цена из NATS (отладка)
`{"symbol":"solusdt","price":"101.45000000","ts":1789334371122}`. Пока цены нет → `503`.
### `POST /close` — **НЕ СУЩЕСТВУЕТ** (404)
Ручки закрытия нет намеренно: закрытие делает **сервер сам по таймеру**.
---
## WebSocket: `GET /api/ws`
`wss://testfront.binom.pw/api/ws`
При подключении сервер сразу шлёт **снапшот цены** (если она уже есть), дальше — поток.
Все события — **широковещательные**: один сокет видит ставки **всех** игроков.
Нужны только свои — фильтруй по полю `bettor`.
### 1. `price` — курс SOL
```json
{"type":"price","symbol":"solusdt","price":"101.02000000","ts":1789326824749}
```
`price` — строка, чтобы не терять точность. Тик — по мере прихода из NATS (медиана ~0.5 с,
провалы до нескольких секунд).
### 2. `bet_open` — ставку поставили (игра началась)
```json
{"type":"bet_open","id":"2","bettor":"8KbUFCHV…","side":"DOWN",
"amountUnits":"500000000","entryPrice":10105000000,"expireTime":1789326835}
```
* `entryPrice` — цена входа, **целое с масштабом 1e8** (`10105000000` = 101.05).
* `expireTime` — unix-**секунды**, момент закрытия окна.
### 3. `bet_closed` — ставку рассчитали (игра закончилась)
```json
{"type":"bet_closed","id":"2","bettor":"8KbUFCHV…",
"exitPrice":10104000000,"status":1,"statusName":"payout_done"}
```
```json
{"type":"bet_closed","id":"3","bettor":"8KbUFCHV…",
"exitPrice":10105000000,"status":3,"statusName":"refunded"}
```
* `exitPrice` — цена выхода, тот же масштаб 1e8.
* `statusName`: `payout_done` (игрок выиграл, выплата с множителем) | `house_won` (выиграл дом, ставка остаётся в ваулте) | `refunded` (ничья при `exitPrice == entryPrice`: залог возвращён игроку ровно суммой ставки, без множителя) | `open` (ещё открыта).
Исход считает контракт: `UP` выигрывает при `exitPrice > entryPrice`, `DOWN` — при `<`.
### Авто-закрытие
Планировщик в шлюзе каждые **2 с** (`SMART_SETTLE_INTERVAL_MS`, дефолт 2000) обходит
ставки программы, для просроченных берёт цену из локальной истории и зовёт `close_bet`.
При успехе всем в сокет уходит `bet_closed`. Клиент **не должен** ничего закрывать сам.
---
## Ничья при `exitPrice == entryPrice`
Если цена на момент экспирации в точности равна цене входа, ставка **не зависает**:
контракт возвращает игроку **ровно сумму ставки без множителя**, ставка переходит в
статус `3`, по WS уходит `bet_closed` со `statusName: "refunded"`.
Доля таких окон — около **трети** ставок (замер 33.6 % на 34 769 окнах). Это **нормальный
штатный исход**, а не ошибка: клиенту надо просто показать третий вид результата.
Рекомендация фронту: обрабатывать **три** исхода `bet_closed` (`payout_done` / `house_won`
/ `refunded`) и не показывать `refunded` как проигрыш.
> Исторически (до 14.09.2026) такие ставки зависали навсегда; разбор —
> `pump-game-ops/docs/SECURITY.md`, пункт `SEC-9`.
---
## Адреса стенда
| Что | Значение |
|---|---|
| Шлюз | `https://testfront.binom.pw` (`/api/…`) |
| WS | `wss://testfront.binom.pw/api/ws` |
| Программа | `9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf` |
| Минт фантиков | `6fxySAjTQTkzyyZ7u8EQqv3SyJtv2Ydr6hphquHbkGS9` (decimals 6) |
| RPC localnet | `http://192.168.76.181:8899` (изнутри сети; рестарт возможен) |
| Источник цены | NATS `market.price.solusdt` (Binance SOL/USDT) |
+31 -29
View File
@@ -1,44 +1,46 @@
# test-front — технический тестовый фронт (Binary Rocket)
Чисто технический веб-дашборд для **ручного тестирования механики экономики**
(пополнение фантиками → ставка → вывод) БЕЗ Unity. Только кнопки + вывод технической
информации и лог. Никакой «красоты» — он для гонок сценариев.
Чисто технический веб-дашборд для **ручной проверки механики** через HTTP-шлюз:
кнопки + лог. Без Phantom, без swap, без вывода средств — только постановка ставок
фантиками и наблюдение за событиями.
## Зачем
Unity-фронт (`fun-game-front2`) хорош для продакшена, но для быстрых итераций по
экономике (swap ввод/вывод, ставка фантиками, поллинг результата) он тяжёлый.
Этот фронт дёргает те же эндпоинты бэкенда и даёт мгновенный отклик.
Технический пульт для проверки контракта «вручную»: быстро прогнать сценарий
«создать кошелёк → пополнить тестовым фаусетом → поставить → дождаться исхода».
Полный контракт API (HTTP + WS, поля событий, коды ошибок) — **[API.md](API.md)**.
## Возможности
- Подключение Phantom, показ адреса.
- Отображение баланса: **фантики** (токен Binary Rocket) + SOL.
- **Пополнить** — swap SOL→фантики через `POST /api/pump/swap` (подпись в Phantom).
- **Вывести** — swap фантики→SOL.
- **Ставка** (вверх/вниз) в фантиках через контракт: `GET /api/state` →
подпись create_bet → `POST /api/bet` → поллинг до SETTLED → показ выигрыша.
- Поля в шапке: **Шлюз (API)**, **WS URL**, **Адрес**.
- **Создать кошелёк** — генерирует новый custodial-адрес через шлюз.
- **Пополнить кошелёк** — тестовый фаусет: наливает SOL и фантики.
- **Сделать ставку** — `POST /api/bet` (`side` `UP`/`DOWN`, `amountUnits`).
- События по **WebSocket** (`price`, `bet_open`, `bet_closed`) — поллинга нет.
- Лог всех действий + кнопки «Копировать» / «Очистить».
## Как пользоваться
1. Открыть `index.html` (или раздать через любой статик-хостинг / nginx).
2. В шапке задать конфиг:
- **API base** — бэкенд (напр. `https://smart-updown.binom.pw`).
- **RPC** — публичный Solana RPC (напр. `https://rpc.solanatracker.io/public`).
- **Program** — адрес контракта (по умолч. `FMM...`).
- **Token mint** — адрес mint наших фантиков.
3. Подключить Phantom.
4. Гонять сценарии: пополнить → сделать ставку → дождаться исхода → вывести.
1. Открыть `https://testfront.binom.pw/` — `index.html` отдаётся из куба
(namespace `game`). Поле «Шлюз (API)» можно оставить пустым — фронт возьмёт
свой адрес.
2. Нажать **Создать кошелёк** — в поле «Адрес» появится custodial-адрес.
3. Нажать **Пополнить кошелёк** — тестовый фаусет нальёт SOL и фантики.
4. Выбрать сторону (`UP` / `DOWN`) и сумму (в пределах `minAmountHuman` /
`maxAmountHuman` из `/api/state`: `100` … `10000` фантиков) → **Сделать ставку**.
5. Результат придёт сам по WS примерно через `expirySeconds` (15 с).
> Примечания
> - Фронт читает/пишет токен-аккаунты через RPC; для Token-2022 decimals читается из mint.
> - STUB-ключи create_bet пока зашиты под SOL-версию контракта. Когда контракт переведут
> на токен (Фаза 1), этот блок обновить под токеновые ключи.
> - API base должен отдавать CORS (nginx-прокси), иначе браузер заблокирует fetch.
Три возможных исхода ставки:
- **выиграл** — `statusName: "payout_done"`, выплата с множителем;
- **проиграл** — `statusName: "house_won"`, ставка остаётся в ваулте;
- **возврат при ничьей** — `statusName: "refunded"`, залог возвращён ровно
суммой ставки без множителя (подробнее — [API.md](API.md), раздел
«Ничья при `exitPrice == entryPrice`»).
> Правило: **одна открытая ставка на адрес** — вторая ставка с того же адреса
> получит `409`. Дождитесь `bet_closed` по WS, прежде чем ставить снова.
## Статус
- [x] каркас: балансы, swap ввод/вывод
- [x] ставка вверх/вниз через контракт (поллинг)
- [ ] перевести подпись под токеновую версию контракта (после Фазы 1)
- [ ] реальный провайдер цены вместо RandomPriceSource (за нами)
- [x] каркас: кошелёк, фаусет, ставка, события по WS
- [x] контракт: возврат при ничьей (`exitPrice == entryPrice`)
+99 -20
View File
@@ -70,6 +70,20 @@
<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">
@@ -81,17 +95,23 @@
</select>
<button onclick="gwBet()">Сделать ставку</button>
</div>
<div class="hint">сумма ×1e6 → units (decimals=6); адрес берётся из поля «Адрес» (пустой адрес — шлюз сам сгенерит и зарегистрирует кошелёк)</div>
<div class="hint">сумма ×1e6 → units (decimals=6). Адрес обязателен: сначала «Создать кошелёк» → «Пополнить кошелёк» (на тесте), затем ставка.</div>
<div class="hint" id="betLimits">лимиты ставок неизвестны (обновите state)</div>
</fieldset>
<fieldset>
<legend>Закрытие (POST /close)</legend>
<legend>Ставка: ход игры (сервер закрывает сам)</legend>
<div class="row">
<label>id ставки</label><input id="closeId" size="12" value=""/>
<button onclick="gwClose()">Закрыть ставку</button>
<span class="hint">id заполнится после ставки, либо введите вручную</span>
<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>
@@ -179,7 +199,43 @@ async function gwBalance(){
}catch(e){ log.err('balance: '+e.message); }
}
let lastBetId = '';
// ---- Кошелёк и пополнение (тест) ----
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;
@@ -193,24 +249,43 @@ async function gwBet(){
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});
lastBetId = String(j.id);
document.getElementById('closeId').value = lastBetId;
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); }
}
async function gwClose(){
try{
const id = document.getElementById('closeId').value.trim();
if(!id){ log.err('close: нет id ставки (сначала сделайте ставку или введите id вручную)'); return; }
const j = await gwFetch('/close', {id:id});
const label = {payout_done:'ВЫИГРАЛ (payout_done)', house_won:'ПРОИГРАЛ (house_won)', open:'открыт (open)'}[j.statusName] || ('status='+j.statusName);
const detail = 'exitPrice='+j.exitPrice+(j.note?' — '+j.note:'');
if(j.statusName==='payout_done') log.ok('Закрыт #'+id+': '+label+', '+detail);
else if(j.statusName==='house_won') log.err('Закрыт #'+id+': '+label+', '+detail);
else log.info('Закрыт #'+id+': '+label+', '+detail);
}catch(e){ log.err('close: '+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(()=>{}); }
@@ -292,7 +367,11 @@ function connectWs(){
ws.onmessage = (ev) => {
let m;
try{ m = JSON.parse(ev.data); }catch(e){ return; }
if(!m || m.type !== 'price') 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);