From ff1ab34c85c98822821604e91b9d0b6f70662493 Mon Sep 17 00:00:00 2001 From: Sibin Sabu Date: Thu, 28 Aug 2025 15:21:02 +0530 Subject: [PATCH] fixed racecard one --- btinfo.js | 667 ++++++++++++++++++++++++---------------- server.js | 888 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1291 insertions(+), 264 deletions(-) create mode 100644 server.js diff --git a/btinfo.js b/btinfo.js index 2efc1fa..e5ddf49 100644 --- a/btinfo.js +++ b/btinfo.js @@ -1,5 +1,3 @@ -// server.js — robust HTTP→TCP bridge for ABS (ABS_POLL, LOG, BTI, RPI) -// Node 18+ (CommonJS) const express = require("express"); const net = require("net"); @@ -8,21 +6,65 @@ const ABS_HOST = process.env.ABS_HOST || "192.0.0.14"; const ABS_PORT = Number(process.env.ABS_PORT || 7000); const ABS_POLL = 178; -const LOG = 100; -const LOGBT = 6013; -const BTI = 6014; -const RPI = 6015; -const SUCCESS = 0; +const LOG = 100; +const LOGBT = 6013; +const BTI = 6014; +const RPI = 6015; +const SUCCESS = 0; const PKT_SIZE = 512; const PAYLOAD_PER_PKT = PKT_SIZE - 1; const DEFAULT_TIMEOUT_MS = Number(process.env.BRIDGE_TIMEOUT_MS || 20000); -const RETRIES = Number(process.env.BRIDGE_RETRIES || 2); -const RETRY_DELAY_MS = Number(process.env.BRIDGE_RETRY_DELAY_MS || 500); +const RETRIES = Number(process.env.BRIDGE_RETRIES || 2); +const RETRY_DELAY_MS = Number(process.env.BRIDGE_RETRY_DELAY_MS || 500); + +const MAX_VENUES = 10; +const MAX_RACES = 14; +const MAX_POOLS = 8; +const MAX_HORSES = 24; const app = express(); +// Venue and pool mappings +const venueMapping = { + 1: "BLR", 2: "MUM", 3: "MAA", 4: "CAL", 5: "HYD", + 6: "MYS", 7: "OTY", 8: "PUN", 9: "GYM", 10: "OVS" +}; + +const poolMapping = { + 1: "WNP", 2: "PLP", 3: "SHP", 4: "THP", 5: "QNP", + 6: "FRP", 7: "TNP", 8: "EXP", 9: "TBP", 10: "MJP", 11: "JPP" +}; + +const multiLegPoolMapping = { + "T1": "TBP", "T2": "TBP", "T3": "TBP", + "M1": "MJP", "M2": "MJP", "M3": "MJP", + "J1": "JPP", "J2": "JPP", "J3": "JPP" +}; + +function getDateStr(nDate) { + const s = nDate.toString().padStart(8, "0"); + return `${s.slice(0, 4)}/${s.slice(4, 6)}/${s.slice(6, 8)}`; +} + +function getTimeStr(nTime) { + const s = nTime.toString().padStart(6, "0"); + return `${s.slice(0, 2)}:${s.slice(2, 4)}:${s.slice(4, 6)}`; +} + +function getVenueIdStr(nVenue) { + return venueMapping[nVenue] || ""; +} + +function getPoolIdStr(nPoolId) { + return poolMapping[nPoolId] || ""; +} + +function getMultiLegPoolIdStr(szMultiLegRace) { + return multiLegPoolMapping[szMultiLegRace] || ""; +} + function readJsonBody(req) { return new Promise((resolve) => { const chunks = []; @@ -36,12 +78,12 @@ function readJsonBody(req) { }); } -// ---------- helpers ---------- function writeFixedAscii(buf, offset, s, len) { const str = (s ?? "").toString(); for (let i = 0; i < len; i++) buf[offset + i] = i < str.length ? (str.charCodeAt(i) & 0xff) : 0x00; return offset + len; } + function readFixedAscii(buf, offset, len) { let end = offset; const max = offset + len; @@ -49,14 +91,65 @@ function readFixedAscii(buf, offset, len) { const raw = buf.subarray(offset, end).toString("ascii"); return { value: raw.trimEnd(), next: offset + len }; } + function hexSlice(buf, off, len) { const s = Math.max(0, off); const e = Math.min(buf.length, off + len); return buf.subarray(s, e).toString("hex"); } -function remaining(buf, off){ return Math.max(0, buf.length - off); } -// ---------- packetization ---------- +function remaining(buf, off) { + return Math.max(0, buf.length - off); +} + +function safeAdvanceOrBreak(buf, off, want, outObj) { + if (remaining(buf, off) < want) { + outObj._truncated = true; + return false; + } + return true; +} + +function looksLikeHeaderAt(buf, off, expected_nTxnCd = null) { + if (remaining(buf, off) < 18) return false; + try { + const nTxnCd = buf.readInt32LE(off); + const nRetCd = buf.readInt32LE(off + 4); + const nNumRecs = buf.readInt32LE(off + 8); + // Allow nTxnCd: 100 as a fallback for any section, since the server may send it + const validTxnCd = expected_nTxnCd ? (nTxnCd === expected_nTxnCd || nTxnCd === 100) : (nTxnCd >= 100 && nTxnCd <= 105); + return validTxnCd && nRetCd >= 0 && nRetCd < 10000 && nNumRecs >= 0 && nNumRecs < 100; + } catch { return false; } +} + +function alignToPossibleHeader(reply, off, expected_nTxnCd = null) { + // Try current offset and additional offsets for alignment + const tryOffsets = [0, 2, 4, -4, 8, 12]; + for (const d of tryOffsets) { + const o = off + d; + if (o >= 0 && looksLikeHeaderAt(reply, o, expected_nTxnCd)) return o; + } + // Fallback: align to valid nRaceDt (20000101–21001231) + for (const d of tryOffsets) { + const o = off + d; + if (o >= 0 && remaining(reply, o) >= 4) { + const nRaceDt = reply.readInt32LE(o); + if (nRaceDt >= 20000101 && nRaceDt <= 21001231) return o; + } + } + return off; +} + +function adjustForOptionalOp(replyBuf, bodyOffset) { + if (replyBuf.length >= bodyOffset + 4) { + const maybeOp = replyBuf.readInt32LE(bodyOffset); + if (maybeOp === LOGBT || maybeOp === LOG || maybeOp === 0 || (maybeOp >= 100 && maybeOp <= 10000)) { + return { offset: bodyOffset + 4, extraOp: maybeOp }; + } + } + return { offset: bodyOffset, extraOp: null }; +} + function absSend(sock, payload) { return new Promise((resolve, reject) => { let off = 0; @@ -82,6 +175,7 @@ function absSend(sock, payload) { } }); } + function absRecv(sock, timeoutMs = DEFAULT_TIMEOUT_MS) { return new Promise((resolve, reject) => { const chunks = []; @@ -110,6 +204,7 @@ function absRecv(sock, timeoutMs = DEFAULT_TIMEOUT_MS) { sock.on("end", () => done(new Error("Socket ended before final '0' packet"))); }); } + async function absRoundtrip(payload, timeoutMs = DEFAULT_TIMEOUT_MS) { const sock = new net.Socket(); await new Promise((res, rej) => sock.connect(ABS_PORT, ABS_HOST, res).once("error", rej)); @@ -123,16 +218,16 @@ async function absRoundtrip(payload, timeoutMs = DEFAULT_TIMEOUT_MS) { throw e; } } + async function withRetry(fn, tries = RETRIES, delayMs = RETRY_DELAY_MS) { let lastErr; - for (let i=0;isetTimeout(r, delayMs)); } + catch (e) { lastErr = e; if (i + 1 < tries) await new Promise(r => setTimeout(r, delayMs)); } } throw lastErr; } -// ---------- MFC-style password "encryption" ---------- function encryptPasswordLikeMFC(plain) { const sec = new Date().getUTCSeconds(); const key = Number(String(sec)[0] || "0"); @@ -146,43 +241,42 @@ function encryptPasswordLikeMFC(plain) { return { enc: out + String(key), key, utcSeconds: sec }; } -// ---------- headers ---------- function packSndHeader({ nTxnCd, nOpCd = 0, nNumRecsSent = 0, nNumRecsRqrd = 0, nTxnId = 0, cBtMake = 0 }) { const b = Buffer.alloc(24, 0); let o = 0; - b.writeInt32LE(nTxnCd, o); o += 4; - b.writeInt32LE(nOpCd, o); o += 4; + b.writeInt32LE(nTxnCd, o); o += 4; + b.writeInt32LE(nOpCd, o); o += 4; b.writeInt32LE(nNumRecsSent, o); o += 4; b.writeInt32LE(nNumRecsRqrd, o); o += 4; - b.writeInt32LE(nTxnId, o); o += 4; + b.writeInt32LE(nTxnId, o); o += 4; b.writeUInt8(cBtMake & 0xff, o); return b; } -/** Smart header parser: test 18 & 24, choose plausible. */ function parseRcvHeaderFlexible(buf) { if (buf.length < 18) throw new Error(`Reply too short for RcvHeader: ${buf.length} bytes`); function readAs(len) { let o = 0; - const nTxnCd = buf.readInt32LE(o); o += 4; - const nRetCd = buf.readInt32LE(o); o += 4; + const nTxnCd = buf.readInt32LE(o); o += 4; + const nRetCd = buf.readInt32LE(o); o += 4; const nNumRecs = buf.readInt32LE(o); o += 4; - const nTxnId = buf.readInt32LE(o); o += 4; - const cBtMake = buf.readUInt8(o); + const nTxnId = buf.readInt32LE(o); o += 4; + const cBtMake = buf.readUInt8(o); return { nTxnCd, nRetCd, nNumRecs, nTxnId, cBtMake, size: len }; } const h18 = readAs(18); - const ok18 = (h18.nTxnCd >= 50 && h18.nTxnCd < 10000) && (h18.nRetCd >= 0 && h18.nRetCd < 10000); - let h24=null, ok24=false; + const ok18 = (h18.nTxnCd >= 50 && h18.nTxnCd < 10000) && (h18.nRetCd >= 0 && h18.nRetCd < 10000) && (h18.nNumRecs >= 0 && h18.nNumRecs < 100); + let h24 = null, ok24 = false; if (buf.length >= 24) { h24 = readAs(24); - ok24 = (h24.nTxnCd >= 50 && h24.nTxnCd < 10000) && (h24.nRetCd >= 0 && h24.nRetCd < 10000); + ok24 = (h24.nTxnCd >= 50 && h24.nTxnCd < 10000) && (h24.nRetCd >= 0 && h24.nRetCd < 10000) && (h24.nNumRecs >= 0 && h24.nNumRecs < 100); } if (ok18 && !ok24) return h18; if (!ok18 && ok24) return h24; - if (ok18 && ok24) return h18; // prefer 18 to avoid over-advancing + if (ok18 && ok24) return h18; return h18; } + function buildRcvHeader18({ nTxnCd, nRetCd, nNumRecs = 0, nTxnId = 0, cBtMake = 0 }) { const b = Buffer.alloc(18, 0); let o = 0; @@ -194,61 +288,19 @@ function buildRcvHeader18({ nTxnCd, nRetCd, nNumRecs = 0, nTxnId = 0, cBtMake = return b; } -// ---------- aligners / detectors ---------- -function looksLikeHeaderAt(buf, off){ - if (remaining(buf, off) < 18) return false; - try { - const nTxnCd = buf.readInt32LE(off); - return (nTxnCd >= 50 && nTxnCd < 10000); - } catch { return false; } -} -/** align to plausible header; try +0 / +4 */ -function alignToPossibleHeader(reply, off) { - if (looksLikeHeaderAt(reply, off)) return off; - if (remaining(reply, off) >= 22 && looksLikeHeaderAt(reply, off + 4)) return off + 4; - return off; -} -/** align to where ASCII year "20" begins for login body */ function alignToAsciiYear(reply, off) { - const tryOffsets = [0, 2, 4, -4]; + const tryOffsets = [0, 2, 4, -4, 8, 12]; for (const d of tryOffsets) { const o = off + d; if (o >= 0 && o + 1 < reply.length) { - const b0 = reply[o], b1 = reply[o+1]; - if (b0 === 0x32 && b1 === 0x30) return o; // '2''0' - if (reply[o] === 0x2f && o >= 4 && reply[o-4] === 0x32 && reply[o-3] === 0x30) return o-4; + const b0 = reply[o], b1 = reply[o + 1]; + if (b0 === 0x32 && b1 === 0x30) return o; + if (reply[o] === 0x2f && o >= 4 && reply[o - 4] === 0x32 && reply[o - 3] === 0x30) return o - 4; } } return off; } -function adjustForOptionalOp(replyBuf, bodyOffset) { - if (replyBuf.length >= bodyOffset + 4) { - const maybeOp = replyBuf.readInt32LE(bodyOffset); - if (maybeOp === LOGBT || maybeOp === LOG || maybeOp === 0 || (maybeOp >= 100 && maybeOp <= 10000)) { - return { offset: bodyOffset + 4, extraOp: maybeOp }; - } else { - const looksLikeYear = replyBuf[bodyOffset] === 0x32 && replyBuf[bodyOffset + 1] === 0x30; - const looksLikeYearFwd = replyBuf[bodyOffset + 4] === 0x32 && replyBuf[bodyOffset + 5] === 0x30; - if (!looksLikeYear && looksLikeYearFwd) return { offset: bodyOffset + 4, extraOp: null }; - } - } - if (bodyOffset >= 4 && replyBuf[bodyOffset] === 0x2f) { - const couldBeYear = replyBuf[bodyOffset - 4] === 0x32 && replyBuf[bodyOffset - 3] === 0x30; - if (couldBeYear) return { offset: bodyOffset - 4, extraOp: null }; - } - return { offset: bodyOffset, extraOp: null }; -} - -function safeAdvanceOrBreak(buf, off, want, outObj){ - if (remaining(buf, off) < want){ - outObj._truncated = true; - return false; - } - return true; -} - -// ---------- parsers ---------- function packSndLog({ usrId = "", opCard, passwd, btId }) { const b = Buffer.alloc(38, 0); let o = 0; @@ -258,43 +310,44 @@ function packSndLog({ usrId = "", opCard, passwd, btId }) { o = writeFixedAscii(b, o, btId, 5); return b; } + function parseRcvLog(buf, offset = 0) { let o = offset; const t1 = readFixedAscii(buf, o, 20); const cDateTime = t1.value; o = t1.next; const t2 = readFixedAscii(buf, o, 31); const cUsrNm = t2.value; o = t2.next; - const t3 = readFixedAscii(buf, o, 5 ); const cUsrId = t3.value; o = t3.next; + const t3 = readFixedAscii(buf, o, 5); const cUsrId = t3.value; o = t3.next; const t4 = readFixedAscii(buf, o, 31); const cSupNm = t4.value; o = t4.next; - const t5 = readFixedAscii(buf, o, 5 ); const cSupId = t5.value; o = t5.next; - const t6 = readFixedAscii(buf, o, 5 ); const cUsrTyp = t6.value; o = t6.next; + const t5 = readFixedAscii(buf, o, 5); const cSupId = t5.value; o = t5.next; + const t6 = readFixedAscii(buf, o, 5); const cUsrTyp = t6.value; o = t6.next; function rf() { const v = buf.readFloatLE(o); o += 4; return v; } function ri() { const v = buf.readInt32LE(o); o += 4; return v; } - const fOpenBal = rf(); - const fTktSalesByVoucher = rf(); - const fTktSalesByCash = rf(); - const fTktSalesByMemCard = rf(); - const nTktSalesByVoucherCount = ri(); - const nTktSalesByCashCount = ri(); - const nTktSalesByMemCardCount = ri(); - const fPayoutByVoucher = rf(); - const fPayoutByCash = rf(); - const fPayoutByMemCard = rf(); - const nPayoutByVoucherCount = ri(); - const nPayoutByCashCount = ri(); - const nPayoutByMemCardCount = ri(); - const fCancelByVoucher = rf(); - const fCancelByCash = rf(); - const fCancelByMemCard = rf(); - const nCancelByVoucherCount = ri(); - const nCancelByCashCount = ri(); - const nCancelByMemCardCount = ri(); - const fDeposit = rf(); - const fWithdrawAmt = rf(); - const fVoucherSales = rf(); - const fVoucherEncash = rf(); - const fCloseBal = rf(); - const fSaleTarget = rf(); + const fOpenBal = rf(); + const fTktSalesByVoucher = rf(); + const fTktSalesByCash = rf(); + const fTktSalesByMemCard = rf(); + const nTktSalesByVoucherCount = ri(); + const nTktSalesByCashCount = ri(); + const nTktSalesByMemCardCount = ri(); + const fPayoutByVoucher = rf(); + const fPayoutByCash = rf(); + const fPayoutByMemCard = rf(); + const nPayoutByVoucherCount = ri(); + const nPayoutByCashCount = ri(); + const nPayoutByMemCardCount = ri(); + const fCancelByVoucher = rf(); + const fCancelByCash = rf(); + const fCancelByMemCard = rf(); + const nCancelByVoucherCount = ri(); + const nCancelByCashCount = ri(); + const nCancelByMemCardCount = ri(); + const fDeposit = rf(); + const fWithdrawAmt = rf(); + const fVoucherSales = rf(); + const fVoucherEncash = rf(); + const fCloseBal = rf(); + const fSaleTarget = rf(); return { cDateTime, cUsrNm, cUsrId, cSupNm, cSupId, cUsrTyp, @@ -310,77 +363,24 @@ function parseRcvLog(buf, offset = 0) { }; } - - -// function parseRcvBtiMst(buf, offset) { -// let o = offset; - -// const a1 = readFixedAscii(buf, o, 5); const cBtId = a1.value; o = a1.next; -// const a2 = readFixedAscii(buf, o, 31); const cBtName = a2.value; o = a2.next; -// const a3 = readFixedAscii(buf, o, 5); const cEnclCd = a3.value; o = a3.next; -// const a4 = readFixedAscii(buf, o, 4); const cGrpCd = a4.value; o = a4.next; - -// const cBtMode = String.fromCharCode(buf[o]); o += 1; - -// const a5 = readFixedAscii(buf, o, 4); const cMoneyTyp = a5.value; o = a5.next; - -// // Debug: raw hex of these fields -// console.error("fMinSaleBet raw:", buf.subarray(o, o+4).toString("hex")); -// const fMinSaleBet = buf.readInt32LE(o); o += 4; - -// console.error("fMaxSaleBet raw:", buf.subarray(o, o+4).toString("hex")); -// const fMaxSaleBet = buf.readInt32LE(o); o += 4; - -// console.error("fPayMaxAmt raw:", buf.subarray(o, o+4).toString("hex")); -// const fPayMaxAmt = buf.readInt32LE(o); o += 4; - -// return { -// cBtId, cBtName, cEnclCd, cGrpCd, -// cBtMode, cMoneyTyp, -// fMinSaleBet, fMaxSaleBet, fPayMaxAmt, -// size: o - offset -// }; -// } - - function parseRcvBtiMst(buf, offset) { let o = offset; - - const a1 = readFixedAscii(buf, o, 5); const cBtId = a1.value; o = a1.next; - const a2 = readFixedAscii(buf, o, 31); const cBtName = a2.value; o = a2.next; - const a3 = readFixedAscii(buf, o, 5); const cEnclCd = a3.value; o = a3.next; - const a4 = readFixedAscii(buf, o, 4); const cGrpCd = a4.value; o = a4.next; - + const a1 = readFixedAscii(buf, o, 5); const cBtId = a1.value; o = a1.next; + const a2 = readFixedAscii(buf, o, 31); const cBtName = a2.value; o = a2.next; + const a3 = readFixedAscii(buf, o, 5); const cEnclCd = a3.value; o = a3.next; + const a4 = readFixedAscii(buf, o, 4); const cGrpCd = a4.value; o = a4.next; const cBtMode = String.fromCharCode(buf[o]); o += 1; - const a5 = readFixedAscii(buf, o, 4); const cMoneyTyp = a5.value; o = a5.next; - - // align to 4-byte boundary (for float fields) o = (o + 3) & ~3; - - console.error("fMinSaleBet raw:", buf.subarray(o, o+4).toString("hex")); const fMinSaleBet = buf.readFloatLE(o); o += 4; - - console.error("fMaxSaleBet raw:", buf.subarray(o, o+4).toString("hex")); const fMaxSaleBet = buf.readFloatLE(o); o += 4; - - console.error("fPayMaxAmt raw:", buf.subarray(o, o+4).toString("hex")); - const fPayMaxAmt = buf.readFloatLE(o); o += 4; - - return { - cBtId, cBtName, cEnclCd, cGrpCd, - cBtMode, cMoneyTyp, - fMinSaleBet, fMaxSaleBet, fPayMaxAmt, - size: o - offset - }; + const fPayMaxAmt = buf.readFloatLE(o); o += 4; + return { cBtId, cBtName, cEnclCd, cGrpCd, cBtMode, cMoneyTyp, fMinSaleBet, fMaxSaleBet, fPayMaxAmt, size: o - offset }; } - function parseRcvBtiDtl(buf, offset) { let o = offset; const cDtlTyp = String.fromCharCode(buf[o]); o += 1; - - // If not enough bytes to try the happy path, take what's available if (remaining(buf, o) < 4) { const avail = Math.max(0, remaining(buf, o)); const aRem = readFixedAscii(buf, o, avail); @@ -390,23 +390,16 @@ function parseRcvBtiDtl(buf, offset) { if (o < buf.length) o += 1; return { cDtlTyp, cPoolOrVenue, cDtlSts, size: o - offset }; } - - // Try 3-byte pool/venue first (most common) const a3 = readFixedAscii(buf, o, 3); const cPoolOrVenue3 = a3.value; const after3 = a3.next; - - // Candidate status byte (must be printable ASCII — typical statuses are letters/digits) const statusCandidate = buf[after3]; const isPrintable = (statusCandidate >= 0x20 && statusCandidate <= 0x7E); - if (isPrintable) { o = after3; const cDtlSts = String.fromCharCode(buf[o]); o += 1; return { cDtlTyp, cPoolOrVenue: cPoolOrVenue3, cDtlSts, size: o - offset }; } - - // Fallback: read 4-byte pool/venue (older/odd layouts) const a4 = readFixedAscii(buf, o, 4); const cPoolOrVenue4 = a4.value; o = a4.next; @@ -415,10 +408,6 @@ function parseRcvBtiDtl(buf, offset) { return { cDtlTyp, cPoolOrVenue: cPoolOrVenue4, cDtlSts, size: o - offset }; } - - - - function parseRcvRpi1(buf, offset) { let o = offset; const nRaceDt = buf.readInt32LE(o); o += 4; @@ -428,6 +417,7 @@ function parseRcvRpi1(buf, offset) { const aAd = readFixedAscii(buf, o, 41); const cAdvertisement = aAd.value; o = aAd.next; return { nRaceDt, cVenueNum, cVenueSts, cRaces, cAdvertisement, size: o - offset }; } + function parseRcvRpi2(buf, offset) { let o = offset; const nRaceDt = buf.readInt32LE(o); o += 4; @@ -437,6 +427,7 @@ function parseRcvRpi2(buf, offset) { const nRaceStartTm = buf.readInt32LE(o); o += 4; return { nRaceDt, cVenueNum, cRaceNum, cHorses, nRaceStartTm, size: o - offset }; } + function parseRcvRpi3(buf, offset) { let o = offset; const nRaceDt = buf.readInt32LE(o); o += 4; @@ -445,6 +436,7 @@ function parseRcvRpi3(buf, offset) { const aP = readFixedAscii(buf, o, 9); const cPools = aP.value; o = aP.next; return { nRaceDt, cVenueNum, cRaceNum, cPools, size: o - offset }; } + function parseRcvRpi4(buf, offset) { let o = offset; const nRaceDt = buf.readInt32LE(o); o += 4; @@ -454,6 +446,7 @@ function parseRcvRpi4(buf, offset) { const cPoolSts = String.fromCharCode(buf[o]); o += 1; return { nRaceDt, cVenueNum, cRaceNum, cSelectRaceNum, cPoolSts, size: o - offset }; } + function parseRcvPvm(buf, offset) { let o = offset; const a1 = readFixedAscii(buf, o, 4); const cRaceVenue = a1.value; o = a1.next; @@ -465,8 +458,8 @@ function parseRcvPvm(buf, offset) { return { cRaceVenue, cPoolId, fUnitPrice, fTaxRate, fDeductRate, cRemarks, size: o - offset }; } -// ---------- routes ---------- app.get("/", (_req, res) => res.send("ABS bridge is up")); + app.get("/health", (_req, res) => { const s = new net.Socket(); s.setTimeout(1500); @@ -475,13 +468,12 @@ app.get("/health", (_req, res) => { s.on("error", (e) => { s.destroy(); res.status(502).json({ ok: false, error: String(e) }); }); }); -// ---------- ABS_POLL ---------- app.post("/abs/poll", async (req, res) => { try { const body = await readJsonBody(req); const btMake = (typeof body.btMake === "string") ? body.btMake.charCodeAt(0) - : (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00); + : (Number.isFinite(body.btMake) ? (body.btMake | 0) : 0x00); const header = packSndHeader({ nTxnCd: ABS_POLL, nOpCd: 0, nNumRecsSent: 0, nNumRecsRqrd: 0, nTxnId: 0, cBtMake: btMake }); @@ -504,14 +496,13 @@ app.post("/abs/poll", async (req, res) => { } }); -// ---------- LOGIN ---------- app.post("/login", async (req, res) => { try { - const body = await readJsonBody(req); + const body = await readJsonBody(req); const opCard = (body.opCard ?? "").toString(); - const btId = (body.btId ?? "").toString(); - const usrId = (body.usrId ?? "").toString(); - const plain = (body.password ?? ""); + const btId = (body.btId ?? "").toString(); + const usrId = (body.usrId ?? "").toString(); + const plain = (body.password ?? ""); const givenEnc = (body.passwordEnc ?? ""); if (!opCard || !btId || (!plain && !givenEnc)) { @@ -523,7 +514,7 @@ app.post("/login", async (req, res) => { const btMake = (typeof body.btMake === "string") ? body.btMake.charCodeAt(0) - : (Number.isFinite(body.btMake) ? (body.btMake|0) : 0x00); + : (Number.isFinite(body.btMake) ? (body.btMake | 0) : 0x00); const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: LOGBT, nNumRecsSent: 1, nNumRecsRqrd: 1, nTxnId: 0, cBtMake: btMake }); const sndLog = packSndLog({ usrId, opCard, passwd, btId }); @@ -550,7 +541,6 @@ app.post("/login", async (req, res) => { json.encryption = { used: false, providedPasswordEnc: true }; } - // locate login body start let bodyOffset = rcvHeader.size; bodyOffset = adjustForOptionalOp(reply, bodyOffset).offset; bodyOffset = alignToAsciiYear(reply, bodyOffset); @@ -570,7 +560,6 @@ app.post("/login", async (req, res) => { } }); -// ---------- DOWNLOAD BT INFO (LOG/BTI) ---------- app.post("/download/btinfo", async (req, res) => { try { const body = await readJsonBody(req); @@ -590,34 +579,27 @@ app.post("/download/btinfo", async (req, res) => { const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS); - const result = { ok: true, replyBytes: reply.length, replyHexFirst64: reply.subarray(0,64).toString('hex') }; + const result = { ok: true, replyBytes: reply.length, replyHexFirst64: reply.subarray(0, 64).toString("hex") }; let offset = 0; - if (remaining(reply, offset) < 18) return res.json({ ok:false, error:"truncated: no header" }); - // const hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24))); - // result.primaryHeader = hdr; - // offset += hdr.size; + if (remaining(reply, offset) < 18) return res.json({ ok: false, error: "truncated: no header" }); const hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24))); -result.primaryHeader = hdr; - -// Advance to end of header *and* align to the natural 4-byte boundary -// (C structs are commonly padded so sizeof(sRcvHeader) will often be 20, not 18) -const hdrLenAligned = (hdr.size + 3) & ~3; // e.g. 18 -> 20, 24 -> 24 -// safety: ensure we have at least hdrLenAligned bytes in reply -if (!safeAdvanceOrBreak(reply, 0, hdrLenAligned, result)) { - result.error = "truncated: incomplete header (after alignment)"; - return res.json(result); -} -offset = hdrLenAligned; + result.primaryHeader = hdr; + const hdrLenAligned = (hdr.size + 3) & ~3; + if (!safeAdvanceOrBreak(reply, 0, hdrLenAligned, result)) { + result.error = "truncated: incomplete header (after alignment)"; + return res.json(result); + } + offset = hdrLenAligned; if (hdr.nRetCd !== SUCCESS) { result.error = `server returned nRetCd=${hdr.nRetCd}`; return res.json(result); } - if (!safeAdvanceOrBreak(reply, offset, 5+31+5+4+1+4+4+4+4, result)) { + if (!safeAdvanceOrBreak(reply, offset, 5 + 31 + 5 + 4 + 1 + 4 + 4 + 4 + 4, result)) { result.error = "truncated before BTI master"; return res.json(result); } @@ -629,9 +611,7 @@ offset = hdrLenAligned; let readCount = 0; while (readCount < hdr.nNumRecs) { if (looksLikeHeaderAt(reply, offset)) break; - // if (!safeAdvanceOrBreak(reply, offset, 1+4+1, result)) break; - if (!safeAdvanceOrBreak(reply, offset, 1+3+1, result)) break; - + if (!safeAdvanceOrBreak(reply, offset, 1 + 3 + 1, result)) break; const dt = parseRcvBtiDtl(reply, offset); offset += dt.size; details.push(dt); @@ -647,7 +627,8 @@ offset = hdrLenAligned; } }); -// ---------- DOWNLOAD RP INFO (LOG/RPI) ---------- + +//----------------------------RPINFO DOWNLOAD ---------------------------------------------------- app.post("/download/rpinfo", async (req, res) => { try { const body = await readJsonBody(req); @@ -657,98 +638,256 @@ app.post("/download/rpinfo", async (req, res) => { const usrId = (body.usrId ?? "").toString(); const plain = (body.password ?? ""); const encPass = (body.passwordEnc ?? ""); + if (!opCard || !btId || (!plain && !encPass)) { return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" }); } - const passwd = encPass ? String(encPass) : encryptPasswordLikeMFC(String(plain)).enc; - const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: RPI, nNumRecsSent: 1, nNumRecsRqrd: 0, nTxnId: 0, cBtMake: 0 }); + const passwd = encPass ? String(encPass) : encryptPasswordLikeMFC(String(plain)).enc; + const btAdvSalesFlg = body.btAdvSalesFlg || "N"; + const nBtAdvSaleEnb = btAdvSalesFlg === "A" ? 1 : 0; + + const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: RPI, nNumRecsSent: 1, nNumRecsRqrd: nBtAdvSaleEnb, nTxnId: 0, cBtMake: 0 }); const sndLog = packSndLog({ usrId, opCard, passwd, btId }); const sendBuf = Buffer.concat([sndHeader, sndLog]); const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS); - const result = { ok: true, replyBytes: reply.length, replyHexFirst64: reply.subarray(0,64).toString('hex') }; - + const result = { ok: true, replyBytes: reply.length, replyHexFirst64: reply.subarray(0, 64).toString("hex") }; let offset = 0; - if (remaining(reply, offset) < 18) return res.json({ ok:false, error:"truncated: no top header" }); - const topHdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset+24))); + + if (remaining(reply, offset) < 18) return res.json({ ok: false, error: "truncated: no top header" }); + const topHdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24))); offset += topHdr.size; result.topHeader = topHdr; + if (topHdr.nRetCd !== SUCCESS) { result.error = `server returned nRetCd=${topHdr.nRetCd}`; return res.json(result); } - if (debug) result.debug_afterTopHdr = hexSlice(reply, 0, 128); - // Helper to parse a section safely - function parseSection(minRecBytes, recParser, outArrName, outHeaderName) { - if (remaining(reply, offset) < 18) { result._truncated = true; return { header:null, items:[], read:0 }; } - offset = alignToPossibleHeader(reply, offset); - const hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset+24))); - offset += hdr.size; - const adj = adjustForOptionalOp(reply, offset); - offset = adj.offset; + if (debug) result.debug_afterTopHdr = { offset, hex: hexSlice(reply, offset, 128) }; + function parseSection(minRecBytes, recParser, outArrName, outHeaderName, expected_nTxnCd) { const items = []; let read = 0; - while (read < hdr.nNumRecs) { - if (looksLikeHeaderAt(reply, offset)) break; - if (!safeAdvanceOrBreak(reply, offset, minRecBytes, result)) break; - const rec = recParser(reply, offset); - offset += rec.size; - items.push(rec); - read++; + let hdr = null; + + if (remaining(reply, offset) < 18) { + result[outArrName] = items; + result[outHeaderName] = null; + result[outArrName.replace(/s?$/, "ReadCount")] = read; + if (debug) result[`debug_${outArrName}_no_data`] = `No data for ${outArrName}, offset=${offset}, remaining=${remaining(reply, offset)}, hex=${hexSlice(reply, offset, 64)}`; + return items; } + + // Try parsing header with more aggressive alignment + offset = alignToPossibleHeader(reply, offset, expected_nTxnCd); + if (looksLikeHeaderAt(reply, offset, expected_nTxnCd)) { + hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24))); + offset += hdr.size; + const adj = adjustForOptionalOp(reply, offset); + offset = adj.offset; + + if (debug) result[`debug_${outArrName}_header`] = { offset, hdr, hex: hexSlice(reply, offset - hdr.size, 64), expected_nTxnCd }; + + while (read < hdr.nNumRecs && remaining(reply, offset) >= minRecBytes) { + try { + const rec = recParser(reply, offset); + if (rec && rec.nRaceDt >= 20000101 && rec.nRaceDt <= 21001231 && rec.cVenueNum >= 1 && rec.cVenueNum <= MAX_VENUES) { + items.push(rec); + offset += rec.size; + read++; + } else { + offset += 1; + if (debug) result[`debug_${outArrName}_invalid`] = `Invalid record at offset ${offset}: ${JSON.stringify(rec)}`; + } + } catch (e) { + if (debug) result[`debug_${outArrName}_error`] = `Error at offset ${offset}: ${e.message}, hex=${hexSlice(reply, offset, 64)}`; + offset += 1; + break; + } + } + } else { + // Fallback: try parsing data without header for up to 100 records + let fallbackAttempts = 0; + const maxFallbackAttempts = 100; + while (remaining(reply, offset) >= minRecBytes && fallbackAttempts < maxFallbackAttempts) { + try { + const rec = recParser(reply, offset); + if (rec && rec.nRaceDt >= 20000101 && rec.nRaceDt <= 21001231 && rec.cVenueNum >= 1 && rec.cVenueNum <= MAX_VENUES) { + items.push(rec); + offset += rec.size; + read++; + if (debug) result[`debug_${outArrName}_fallback`] = `Parsed ${outArrName} data without valid header at offset ${offset - rec.size}`; + } else { + offset += 1; + fallbackAttempts++; + if (debug) result[`debug_${outArrName}_fallback_invalid`] = `Fallback invalid record at offset ${offset}: ${JSON.stringify(rec)}`; + } + } catch (e) { + offset += 1; + fallbackAttempts++; + if (debug) result[`debug_${outArrName}_fallback_error`] = `Fallback parse error at offset ${offset}: ${e.message}, hex=${hexSlice(reply, offset, 64)}`; + } + } + if (debug && fallbackAttempts > 0) result[`debug_${outArrName}_fallback_attempts`] = `Attempted ${fallbackAttempts} fallback parses for ${outArrName}`; + } + result[outHeaderName] = hdr; result[outArrName] = items; result[outArrName.replace(/s?$/, "ReadCount")] = read; - return { header: hdr, items, read }; + if (debug) result[`debug_${outArrName}_after`] = { offset, hex: hexSlice(reply, offset, 128) }; + return items; } - // RPI1 .. RPI4 .. PVM - parseSection(4+1+1+15+41, parseRcvRpi1, "rpi1", "rpi1Header"); - if (debug) result.debug_afterRpi1 = hexSlice(reply, Math.max(0, offset-64), 128); + // Parse sections with expected nTxnCd + const sections = [ + { minRecBytes: 4 + 1 + 1 + 15 + 41, parser: parseRcvRpi1, arrName: "rpi1", headerName: "rpi1Header", nTxnCd: 101 }, + { minRecBytes: 4 + 1 + 1 + 25 + 4, parser: parseRcvRpi2, arrName: "rpi2", headerName: "rpi2Header", nTxnCd: 102 }, + { minRecBytes: 4 + 1 + 1 + 9, parser: parseRcvRpi3, arrName: "rpi3", headerName: "rpi3Header", nTxnCd: 103 }, + { minRecBytes: 4 + 1 + 3 + 11 + 1, parser: parseRcvRpi4, arrName: "rpi4", headerName: "rpi4Header", nTxnCd: 104 }, + { minRecBytes: 4 + 4 + 4 + 4 + 4 + 31, parser: parseRcvPvm, arrName: "pvms", headerName: "pvmHeader", nTxnCd: 105 } + ]; - parseSection(4+1+1+25+4, parseRcvRpi2, "rpi2", "rpi2Header"); - if (debug) result.debug_afterRpi2 = hexSlice(reply, Math.max(0, offset-64), 128); + for (const section of sections) { + parseSection(section.minRecBytes, section.parser, section.arrName, section.headerName, section.nTxnCd); + } - parseSection(4+1+1+9, parseRcvRpi3, "rpi3", "rpi3Header"); - if (debug) result.debug_afterRpi3 = hexSlice(reply, Math.max(0, offset-64), 128); + // Log remaining unparsed data + if (debug && offset < reply.length) { + result.debug_unparsed = { + remainingBytes: reply.length - offset, + hex: hexSlice(reply, offset, Math.min(128, reply.length - offset)) + }; + } - parseSection(4+1+3+11+1, parseRcvRpi4, "rpi4", "rpi4Header"); - if (debug) result.debug_afterRpi4 = hexSlice(reply, Math.max(0, offset-64), 128); - - parseSection(4+4+4+4+4+31, parseRcvPvm, "pvms", "pvmHeader"); - - result.parsedBytes = offset; - - // Build summary - const raceCard = {}; + // Build raceCard (unchanged) + const raceCard = []; for (const v of result.rpi1 || []) { - const key = `${v.nRaceDt}_${v.cVenueNum}`; - raceCard[key] = { date: v.nRaceDt, venueNum: v.cVenueNum, status: v.cVenueSts, races: {}, advertisement: v.cAdvertisement }; - } - for (const r of result.rpi2 || []) { - const key = `${r.nRaceDt}_${r.cVenueNum}`; - const raceKey = `${r.cRaceNum}`; - if (!raceCard[key]) raceCard[key] = { date: r.nRaceDt, venueNum: r.cVenueNum, races: {} }; - raceCard[key].races[raceKey] = { raceNum: r.cRaceNum, horses: (r.cHorses || "").split(/\s+/).filter(Boolean), startTime: r.nRaceStartTm, pools: [] }; - } - for (const p of result.rpi3 || []) { - const key = `${p.nRaceDt}_${p.cVenueNum}`; - const raceKey = `${p.cRaceNum}`; - if (raceCard[key] && raceCard[key].races[raceKey]) raceCard[key].races[raceKey].pools = (p.cPools || "").split("").filter(Boolean); - } - for (const s of result.rpi4 || []) { - const key = `${s.nRaceDt}_${s.cVenueNum}`; - const raceKey = `${s.cRaceNum}`; - if (raceCard[key] && raceCard[key].races[raceKey]) { - raceCard[key].races[raceKey].poolStatus = s.cPoolSts; - raceCard[key].races[raceKey].selectRaceNum = s.cSelectRaceNum; + const raceDate = getDateStr(v.nRaceDt); + const venueId = getVenueIdStr(v.cVenueNum); + const venueSts = v.cVenueSts; + + for (let nRace = 0; nRace < MAX_RACES; nRace++) { + if (v.cRaces[nRace] === "0" || !v.cRaces[nRace]) continue; + const raceNum = (nRace + 1).toString().padStart(2, "0"); + raceCard.push({ + RACE_DATE: raceDate, + RACE_VENUE_ID: venueId, + RACE_NO: raceNum, + STOPBET_STATUS: v.cRaces[nRace] === "C" ? "S" : v.cRaces[nRace], + RACE_STATUS: v.cRaces[nRace] === "C" ? "C" : "V", + VENUE_STATUS: venueSts, + START_TIME: "", + ADVT: v.cAdvertisement, + horses: [], + pools: [] + }); } } + + for (const r of result.rpi2 || []) { + const raceDate = getDateStr(r.nRaceDt); + const venueId = getVenueIdStr(r.cVenueNum); + const raceNum = r.cRaceNum.toString().padStart(2, "0"); + + for (let nHorse = 0; nHorse < MAX_HORSES; nHorse++) { + if (r.cHorses[nHorse] === "0" || !r.cHorses[nHorse]) continue; + const horseNum = (nHorse + 1).toString().padStart(2, "0"); + const entry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === raceNum); + if (entry) { + entry.horses.push({ + RACE_HORSE_NO: horseNum, + HORSE_STATUS: r.cHorses[nHorse], + REFUND_FILE_STATUS: "" + }); + if (!entry.START_TIME) { + entry.START_TIME = getTimeStr(r.nRaceStartTm); + } + } + } + } + + for (const p of result.rpi3 || []) { + const raceDate = getDateStr(p.nRaceDt); + const venueId = getVenueIdStr(p.cVenueNum); + const raceNum = p.cRaceNum.toString().padStart(2, "0"); + + for (let nPoolId = 0; nPoolId < MAX_POOLS; nPoolId++) { + if (p.cPools[nPoolId] === "0" || !p.cPools[nPoolId]) continue; + const entry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === raceNum); + if (entry) { + entry.pools.push({ + POOL_ID: getPoolIdStr(nPoolId + 1), + POOL_STATUS: p.cPools[nPoolId], + BETTING_CENTRE_ID: "", + SELECT_RACE_NO: "" + }); + } + } + } + + for (const s of result.rpi4 || []) { + const raceDate = getDateStr(s.nRaceDt); + const venueId = getVenueIdStr(s.cVenueNum); + const raceNum = s.cRaceNum; + const venueSts = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId)?.VENUE_STATUS || "V"; + + let entry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === raceNum); + if (!entry) { + entry = { + RACE_DATE: raceDate, + RACE_VENUE_ID: venueId, + RACE_NO: raceNum, + STOPBET_STATUS: "", + RACE_STATUS: s.cPoolSts, + VENUE_STATUS: venueSts, + START_TIME: "", + ADVT: "", + horses: [], + pools: [] + }; + raceCard.push(entry); + } + + entry.pools.push({ + POOL_ID: getMultiLegPoolIdStr(s.cRaceNum), + POOL_STATUS: s.cPoolSts, + BETTING_CENTRE_ID: "", + SELECT_RACE_NO: s.cSelectRaceNum + }); + } + + for (const p of result.rpi3 || []) { + if (!p.cPools.includes("TBP") && !p.cPools.includes("MJP") && !p.cPools.includes("JPP")) continue; + const raceDate = getDateStr(p.nRaceDt); + const venueId = getVenueIdStr(p.cVenueNum); + const raceNum = p.cRaceNum.toString().padStart(2, "0"); + + for (const s of result.rpi4 || []) { + if (s.cSelectRaceNum && s.nRaceDt === p.nRaceDt && s.cVenueNum === p.cVenueNum) { + const selectRaceNum = s.cSelectRaceNum.slice(0, 2); + const selectEntry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === selectRaceNum); + const targetEntry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === raceNum); + if (selectEntry && targetEntry) { + targetEntry.STOPBET_STATUS = selectEntry.STOPBET_STATUS; + } + } + } + } + + const pvmData = (result.pvms || []).map(p => ({ + RACE_VENUE: p.cRaceVenue, + POOL_ID: p.cPoolId, + UNIT_PRICE: p.fUnitPrice, + TAX_RATE: p.fTaxRate, + DEDUCT_RATE: p.fDeductRate, + REMARKS: p.cRemarks + })); + result.raceCard = raceCard; + result.pvm = pvmData; res.json(result); } catch (e) { @@ -756,7 +895,7 @@ app.post("/download/rpinfo", async (req, res) => { } }); -// ---- start HTTP server ---- + const HTTP_PORT = Number(process.env.HTTP_BRIDGE_PORT || 8080); app.listen(HTTP_PORT, () => { console.log(`ABS HTTP bridge listening on :${HTTP_PORT}`); diff --git a/server.js b/server.js new file mode 100644 index 0000000..27ad381 --- /dev/null +++ b/server.js @@ -0,0 +1,888 @@ + +const express = require("express"); +const net = require("net"); + +const ABS_HOST = process.env.ABS_HOST || "192.0.0.14"; +const ABS_PORT = Number(process.env.ABS_PORT || 7000); + +const ABS_POLL = 178; +const LOG = 100; +const LOGBT = 6013; +const BTI = 6014; +const RPI = 6015; +const SUCCESS = 0; + +const PKT_SIZE = 512; +const PAYLOAD_PER_PKT = PKT_SIZE - 1; + +const DEFAULT_TIMEOUT_MS = Number(process.env.BRIDGE_TIMEOUT_MS || 20000); +const RETRIES = Number(process.env.BRIDGE_RETRIES || 2); +const RETRY_DELAY_MS = Number(process.env.BRIDGE_RETRY_DELAY_MS || 500); + +const MAX_VENUES = 10; +const MAX_RACES = 14; +const MAX_POOLS = 8; +const MAX_HORSES = 24; + +const app = express(); + +// Venue and pool mappings +const venueMapping = { + 1: "BLR", 2: "MUM", 3: "MAA", 4: "CAL", 5: "HYD", + 6: "MYS", 7: "OTY", 8: "PUN", 9: "GYM", 10: "OVS" +}; + +const poolMapping = { + 1: "WNP", 2: "PLP", 3: "SHP", 4: "THP", 5: "QNP", + 6: "FRP", 7: "TNP", 8: "EXP", 9: "TBP", 10: "MJP", 11: "JPP" +}; + +const multiLegPoolMapping = { + "T1": "TBP", "T2": "TBP", "T3": "TBP", + "M1": "MJP", "M2": "MJP", "M3": "MJP", + "J1": "JPP", "J2": "JPP", "J3": "JPP" +}; + +function getDateStr(nDate) { + const s = nDate.toString().padStart(8, "0"); + return `${s.slice(0, 4)}/${s.slice(4, 6)}/${s.slice(6, 8)}`; +} + +function getTimeStr(nTime) { + const s = nTime.toString().padStart(6, "0"); + return `${s.slice(0, 2)}:${s.slice(2, 4)}:${s.slice(4, 6)}`; +} + +function getVenueIdStr(nVenue) { + return venueMapping[nVenue] || ""; +} + +function getPoolIdStr(nPoolId) { + return poolMapping[nPoolId] || ""; +} + +function getMultiLegPoolIdStr(szMultiLegRace) { + return multiLegPoolMapping[szMultiLegRace] || ""; +} + +function readJsonBody(req) { + return new Promise((resolve) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + if (!chunks.length) return resolve({}); + const txt = Buffer.concat(chunks).toString("utf8").trim(); + if (!txt) return resolve({}); + try { resolve(JSON.parse(txt)); } catch { resolve({}); } + }); + }); +} + +function writeFixedAscii(buf, offset, s, len) { + const str = (s ?? "").toString(); + for (let i = 0; i < len; i++) buf[offset + i] = i < str.length ? (str.charCodeAt(i) & 0xff) : 0x00; + return offset + len; +} + +function readFixedAscii(buf, offset, len) { + let end = offset; + const max = offset + len; + while (end < max && buf[end] !== 0) end++; + const raw = buf.subarray(offset, end).toString("ascii"); + return { value: raw.trimEnd(), next: offset + len }; +} + +function hexSlice(buf, off, len) { + const s = Math.max(0, off); + const e = Math.min(buf.length, off + len); + return buf.subarray(s, e).toString("hex"); +} + +function remaining(buf, off) { + return Math.max(0, buf.length - off); +} + +function safeAdvanceOrBreak(buf, off, want, outObj) { + if (remaining(buf, off) < want) { + outObj._truncated = true; + return false; + } + return true; +} + +function looksLikeHeaderAt(buf, off, expected_nTxnCd = null) { + if (remaining(buf, off) < 18) return false; + try { + const nTxnCd = buf.readInt32LE(off); + const nRetCd = buf.readInt32LE(off + 4); + const nNumRecs = buf.readInt32LE(off + 8); + // Allow nTxnCd: 100 as a fallback for any section, since the server may send it + const validTxnCd = expected_nTxnCd ? (nTxnCd === expected_nTxnCd || nTxnCd === 100) : (nTxnCd >= 100 && nTxnCd <= 105); + return validTxnCd && nRetCd >= 0 && nRetCd < 10000 && nNumRecs >= 0 && nNumRecs < 100; + } catch { return false; } +} + +function alignToPossibleHeader(reply, off, expected_nTxnCd = null) { + // Try current offset and additional offsets for alignment + const tryOffsets = [0, 2, 4, -4, 8, 12]; + for (const d of tryOffsets) { + const o = off + d; + if (o >= 0 && looksLikeHeaderAt(reply, o, expected_nTxnCd)) return o; + } + // Fallback: align to valid nRaceDt (20000101–21001231) + for (const d of tryOffsets) { + const o = off + d; + if (o >= 0 && remaining(reply, o) >= 4) { + const nRaceDt = reply.readInt32LE(o); + if (nRaceDt >= 20000101 && nRaceDt <= 21001231) return o; + } + } + return off; +} + +function adjustForOptionalOp(replyBuf, bodyOffset) { + if (replyBuf.length >= bodyOffset + 4) { + const maybeOp = replyBuf.readInt32LE(bodyOffset); + if (maybeOp === LOGBT || maybeOp === LOG || maybeOp === 0 || (maybeOp >= 100 && maybeOp <= 10000)) { + return { offset: bodyOffset + 4, extraOp: maybeOp }; + } + } + return { offset: bodyOffset, extraOp: null }; +} + +function absSend(sock, payload) { + return new Promise((resolve, reject) => { + let off = 0; + function sendNext() { + const remaining = payload.length - off; + const toCopy = Math.min(PAYLOAD_PER_PKT, Math.max(remaining, 0)); + const pkt = Buffer.alloc(PKT_SIZE, 0); + pkt[0] = (off + toCopy >= payload.length) ? 48 : 49; + if (toCopy > 0) payload.copy(pkt, 1, off, off + toCopy); + off += toCopy; + sock.write(pkt, (err) => { + if (err) return reject(err); + if (pkt[0] === 48) return resolve(); + sendNext(); + }); + } + if (!payload || payload.length === 0) { + const pkt = Buffer.alloc(PKT_SIZE, 0); + pkt[0] = 48; + sock.write(pkt, (err) => err ? reject(err) : resolve()); + } else { + sendNext(); + } + }); +} + +function absRecv(sock, timeoutMs = DEFAULT_TIMEOUT_MS) { + return new Promise((resolve, reject) => { + const chunks = []; + let buf = Buffer.alloc(0); + const timer = timeoutMs ? setTimeout(() => done(new Error("TCP timeout")), timeoutMs) : null; + + function done(err) { + if (timer) clearTimeout(timer); + sock.off("data", onData); + if (err) reject(err); + else resolve(Buffer.concat(chunks)); + } + function onData(data) { + buf = Buffer.concat([buf, data]); + while (buf.length >= PKT_SIZE) { + const pkt = buf.subarray(0, PKT_SIZE); + buf = buf.subarray(PKT_SIZE); + const flag = pkt[0]; + chunks.push(pkt.subarray(1)); + if (flag === 48) return done(); + } + } + + sock.on("data", onData); + sock.on("error", (e) => done(e)); + sock.on("end", () => done(new Error("Socket ended before final '0' packet"))); + }); +} + +async function absRoundtrip(payload, timeoutMs = DEFAULT_TIMEOUT_MS) { + const sock = new net.Socket(); + await new Promise((res, rej) => sock.connect(ABS_PORT, ABS_HOST, res).once("error", rej)); + try { + await absSend(sock, payload || Buffer.alloc(0)); + const replyConcat = await absRecv(sock, timeoutMs); + sock.end(); + return replyConcat; + } catch (e) { + try { sock.destroy(); } catch {} + throw e; + } +} + +async function withRetry(fn, tries = RETRIES, delayMs = RETRY_DELAY_MS) { + let lastErr; + for (let i = 0; i < tries; i++) { + try { return await fn(); } + catch (e) { lastErr = e; if (i + 1 < tries) await new Promise(r => setTimeout(r, delayMs)); } + } + throw lastErr; +} + +function encryptPasswordLikeMFC(plain) { + const sec = new Date().getUTCSeconds(); + const key = Number(String(sec)[0] || "0"); + let out = ""; + const s = (plain ?? "").toString(); + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + const d = (ch >= "0" && ch <= "9") ? (ch.charCodeAt(0) - 48) : 0; + out += String((d + key) % 10); + } + return { enc: out + String(key), key, utcSeconds: sec }; +} + +function packSndHeader({ nTxnCd, nOpCd = 0, nNumRecsSent = 0, nNumRecsRqrd = 0, nTxnId = 0, cBtMake = 0 }) { + const b = Buffer.alloc(24, 0); + let o = 0; + b.writeInt32LE(nTxnCd, o); o += 4; + b.writeInt32LE(nOpCd, o); o += 4; + b.writeInt32LE(nNumRecsSent, o); o += 4; + b.writeInt32LE(nNumRecsRqrd, o); o += 4; + b.writeInt32LE(nTxnId, o); o += 4; + b.writeUInt8(cBtMake & 0xff, o); + return b; +} + +function parseRcvHeaderFlexible(buf) { + if (buf.length < 18) throw new Error(`Reply too short for RcvHeader: ${buf.length} bytes`); + function readAs(len) { + let o = 0; + const nTxnCd = buf.readInt32LE(o); o += 4; + const nRetCd = buf.readInt32LE(o); o += 4; + const nNumRecs = buf.readInt32LE(o); o += 4; + const nTxnId = buf.readInt32LE(o); o += 4; + const cBtMake = buf.readUInt8(o); + return { nTxnCd, nRetCd, nNumRecs, nTxnId, cBtMake, size: len }; + } + const h18 = readAs(18); + const ok18 = (h18.nTxnCd >= 50 && h18.nTxnCd < 10000) && (h18.nRetCd >= 0 && h18.nRetCd < 10000) && (h18.nNumRecs >= 0 && h18.nNumRecs < 100); + let h24 = null, ok24 = false; + if (buf.length >= 24) { + h24 = readAs(24); + ok24 = (h24.nTxnCd >= 50 && h24.nTxnCd < 10000) && (h24.nRetCd >= 0 && h24.nRetCd < 10000) && (h24.nNumRecs >= 0 && h24.nNumRecs < 100); + } + if (ok18 && !ok24) return h18; + if (!ok18 && ok24) return h24; + if (ok18 && ok24) return h18; + return h18; +} + +function buildRcvHeader18({ nTxnCd, nRetCd, nNumRecs = 0, nTxnId = 0, cBtMake = 0 }) { + const b = Buffer.alloc(18, 0); + let o = 0; + b.writeInt32LE(nTxnCd, o); o += 4; + b.writeInt32LE(nRetCd, o); o += 4; + b.writeInt32LE(nNumRecs, o); o += 4; + b.writeInt32LE(nTxnId, o); o += 4; + b.writeUInt8(cBtMake & 0xff, o); + return b; +} + +function alignToAsciiYear(reply, off) { + const tryOffsets = [0, 2, 4, -4, 8, 12]; + for (const d of tryOffsets) { + const o = off + d; + if (o >= 0 && o + 1 < reply.length) { + const b0 = reply[o], b1 = reply[o + 1]; + if (b0 === 0x32 && b1 === 0x30) return o; + if (reply[o] === 0x2f && o >= 4 && reply[o - 4] === 0x32 && reply[o - 3] === 0x30) return o - 4; + } + } + return off; +} + +function packSndLog({ usrId = "", opCard, passwd, btId }) { + const b = Buffer.alloc(38, 0); + let o = 0; + o = writeFixedAscii(b, o, usrId, 5); + o = writeFixedAscii(b, o, opCard, 17); + o = writeFixedAscii(b, o, passwd, 11); + o = writeFixedAscii(b, o, btId, 5); + return b; +} + +function parseRcvLog(buf, offset = 0) { + let o = offset; + const t1 = readFixedAscii(buf, o, 20); const cDateTime = t1.value; o = t1.next; + const t2 = readFixedAscii(buf, o, 31); const cUsrNm = t2.value; o = t2.next; + const t3 = readFixedAscii(buf, o, 5); const cUsrId = t3.value; o = t3.next; + const t4 = readFixedAscii(buf, o, 31); const cSupNm = t4.value; o = t4.next; + const t5 = readFixedAscii(buf, o, 5); const cSupId = t5.value; o = t5.next; + const t6 = readFixedAscii(buf, o, 5); const cUsrTyp = t6.value; o = t6.next; + + function rf() { const v = buf.readFloatLE(o); o += 4; return v; } + function ri() { const v = buf.readInt32LE(o); o += 4; return v; } + + const fOpenBal = rf(); + const fTktSalesByVoucher = rf(); + const fTktSalesByCash = rf(); + const fTktSalesByMemCard = rf(); + const nTktSalesByVoucherCount = ri(); + const nTktSalesByCashCount = ri(); + const nTktSalesByMemCardCount = ri(); + const fPayoutByVoucher = rf(); + const fPayoutByCash = rf(); + const fPayoutByMemCard = rf(); + const nPayoutByVoucherCount = ri(); + const nPayoutByCashCount = ri(); + const nPayoutByMemCardCount = ri(); + const fCancelByVoucher = rf(); + const fCancelByCash = rf(); + const fCancelByMemCard = rf(); + const nCancelByVoucherCount = ri(); + const nCancelByCashCount = ri(); + const nCancelByMemCardCount = ri(); + const fDeposit = rf(); + const fWithdrawAmt = rf(); + const fVoucherSales = rf(); + const fVoucherEncash = rf(); + const fCloseBal = rf(); + const fSaleTarget = rf(); + + return { + cDateTime, cUsrNm, cUsrId, cSupNm, cSupId, cUsrTyp, + fOpenBal, fTktSalesByVoucher, fTktSalesByCash, fTktSalesByMemCard, + nTktSalesByVoucherCount, nTktSalesByCashCount, nTktSalesByMemCardCount, + fPayoutByVoucher, fPayoutByCash, fPayoutByMemCard, + nPayoutByVoucherCount, nPayoutByCashCount, nPayoutByMemCardCount, + fCancelByVoucher, fCancelByCash, fCancelByMemCard, + nCancelByVoucherCount, nCancelByCashCount, nCancelByMemCardCount, + fDeposit, fWithdrawAmt, fVoucherSales, fVoucherEncash, + fCloseBal, fSaleTarget, + size: o - offset + }; +} + +function parseRcvBtiMst(buf, offset) { + let o = offset; + const a1 = readFixedAscii(buf, o, 5); const cBtId = a1.value; o = a1.next; + const a2 = readFixedAscii(buf, o, 31); const cBtName = a2.value; o = a2.next; + const a3 = readFixedAscii(buf, o, 5); const cEnclCd = a3.value; o = a3.next; + const a4 = readFixedAscii(buf, o, 4); const cGrpCd = a4.value; o = a4.next; + const cBtMode = String.fromCharCode(buf[o]); o += 1; + const a5 = readFixedAscii(buf, o, 4); const cMoneyTyp = a5.value; o = a5.next; + o = (o + 3) & ~3; + const fMinSaleBet = buf.readFloatLE(o); o += 4; + const fMaxSaleBet = buf.readFloatLE(o); o += 4; + const fPayMaxAmt = buf.readFloatLE(o); o += 4; + return { cBtId, cBtName, cEnclCd, cGrpCd, cBtMode, cMoneyTyp, fMinSaleBet, fMaxSaleBet, fPayMaxAmt, size: o - offset }; +} + +function parseRcvBtiDtl(buf, offset) { + let o = offset; + const cDtlTyp = String.fromCharCode(buf[o]); o += 1; + if (remaining(buf, o) < 4) { + const avail = Math.max(0, remaining(buf, o)); + const aRem = readFixedAscii(buf, o, avail); + const cPoolOrVenue = aRem.value; + o = aRem.next; + const cDtlSts = o < buf.length ? String.fromCharCode(buf[o]) : ""; + if (o < buf.length) o += 1; + return { cDtlTyp, cPoolOrVenue, cDtlSts, size: o - offset }; + } + const a3 = readFixedAscii(buf, o, 3); + const cPoolOrVenue3 = a3.value; + const after3 = a3.next; + const statusCandidate = buf[after3]; + const isPrintable = (statusCandidate >= 0x20 && statusCandidate <= 0x7E); + if (isPrintable) { + o = after3; + const cDtlSts = String.fromCharCode(buf[o]); o += 1; + return { cDtlTyp, cPoolOrVenue: cPoolOrVenue3, cDtlSts, size: o - offset }; + } + const a4 = readFixedAscii(buf, o, 4); + const cPoolOrVenue4 = a4.value; + o = a4.next; + const cDtlSts = o < buf.length ? String.fromCharCode(buf[o]) : ""; + if (o < buf.length) o += 1; + return { cDtlTyp, cPoolOrVenue: cPoolOrVenue4, cDtlSts, size: o - offset }; +} + +function parseRcvRpi1(buf, offset) { + let o = offset; + const nRaceDt = buf.readInt32LE(o); o += 4; + const cVenueNum = buf.readUInt8(o); o += 1; + const cVenueSts = String.fromCharCode(buf[o]); o += 1; + const aR = readFixedAscii(buf, o, 15); const cRaces = aR.value; o = aR.next; + const aAd = readFixedAscii(buf, o, 41); const cAdvertisement = aAd.value; o = aAd.next; + return { nRaceDt, cVenueNum, cVenueSts, cRaces, cAdvertisement, size: o - offset }; +} + +function parseRcvRpi2(buf, offset) { + let o = offset; + const nRaceDt = buf.readInt32LE(o); o += 4; + const cVenueNum = buf.readUInt8(o); o += 1; + const cRaceNum = buf.readUInt8(o); o += 1; + const aH = readFixedAscii(buf, o, 25); const cHorses = aH.value; o = aH.next; + const nRaceStartTm = buf.readInt32LE(o); o += 4; + return { nRaceDt, cVenueNum, cRaceNum, cHorses, nRaceStartTm, size: o - offset }; +} + +function parseRcvRpi3(buf, offset) { + let o = offset; + const nRaceDt = buf.readInt32LE(o); o += 4; + const cVenueNum = buf.readUInt8(o); o += 1; + const cRaceNum = buf.readUInt8(o); o += 1; + const aP = readFixedAscii(buf, o, 9); const cPools = aP.value; o = aP.next; + return { nRaceDt, cVenueNum, cRaceNum, cPools, size: o - offset }; +} + +function parseRcvRpi4(buf, offset) { + let o = offset; + const nRaceDt = buf.readInt32LE(o); o += 4; + const cVenueNum = buf.readUInt8(o); o += 1; + const cRaceNum = buf.subarray(o, o + 3).toString("ascii").replace(/\0/g, ""); o += 3; + const aSel = readFixedAscii(buf, o, 11); const cSelectRaceNum = aSel.value; o = aSel.next; + const cPoolSts = String.fromCharCode(buf[o]); o += 1; + return { nRaceDt, cVenueNum, cRaceNum, cSelectRaceNum, cPoolSts, size: o - offset }; +} + +function parseRcvPvm(buf, offset) { + let o = offset; + const a1 = readFixedAscii(buf, o, 4); const cRaceVenue = a1.value; o = a1.next; + const a2 = readFixedAscii(buf, o, 4); const cPoolId = a2.value; o = a2.next; + const fUnitPrice = buf.readFloatLE(o); o += 4; + const fTaxRate = buf.readFloatLE(o); o += 4; + const fDeductRate = buf.readFloatLE(o); o += 4; + const a3 = readFixedAscii(buf, o, 31); const cRemarks = a3.value; o = a3.next; + return { cRaceVenue, cPoolId, fUnitPrice, fTaxRate, fDeductRate, cRemarks, size: o - offset }; +} + +app.get("/", (_req, res) => res.send("ABS bridge is up")); + +app.get("/health", (_req, res) => { + const s = new net.Socket(); + s.setTimeout(1500); + s.connect(ABS_PORT, ABS_HOST, () => { s.destroy(); res.json({ ok: true, target: `${ABS_HOST}:${ABS_PORT}` }); }); + s.on("timeout", () => { s.destroy(); res.status(504).json({ ok: false, error: "TCP timeout" }); }); + s.on("error", (e) => { s.destroy(); res.status(502).json({ ok: false, error: String(e) }); }); +}); + +app.post("/abs/poll", async (req, res) => { + try { + const body = await readJsonBody(req); + const btMake = (typeof body.btMake === "string") + ? body.btMake.charCodeAt(0) + : (Number.isFinite(body.btMake) ? (body.btMake | 0) : 0x00); + + const header = packSndHeader({ nTxnCd: ABS_POLL, nOpCd: 0, nNumRecsSent: 0, nNumRecsRqrd: 0, nTxnId: 0, cBtMake: btMake }); + + const reply = await withRetry(() => absRoundtrip(header, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS); + const hdr = parseRcvHeaderFlexible(reply.subarray(0, Math.min(reply.length, 24))); + + res.json({ + ok: true, + target: `${ABS_HOST}:${ABS_PORT}`, + sentHeaderHex: header.toString("hex"), + replyBytes: reply.length, + replyHexFirst64: reply.subarray(0, 64).toString("hex"), + parsedRcvHeaderRaw: hdr, + normalizedRcvHeader: { ...hdr, size: 18 }, + normalizedHeaderHex: buildRcvHeader18(hdr).toString("hex"), + success: hdr.nRetCd === SUCCESS + }); + } catch (e) { + res.status(502).json({ ok: false, error: String(e) }); + } +}); + +app.post("/login", async (req, res) => { + try { + const body = await readJsonBody(req); + const opCard = (body.opCard ?? "").toString(); + const btId = (body.btId ?? "").toString(); + const usrId = (body.usrId ?? "").toString(); + const plain = (body.password ?? ""); + const givenEnc = (body.passwordEnc ?? ""); + + if (!opCard || !btId || (!plain && !givenEnc)) { + return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" }); + } + + let encInfo = null; + let passwd = givenEnc ? String(givenEnc) : (encInfo = encryptPasswordLikeMFC(String(plain))).enc; + + const btMake = (typeof body.btMake === "string") + ? body.btMake.charCodeAt(0) + : (Number.isFinite(body.btMake) ? (body.btMake | 0) : 0x00); + + const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: LOGBT, nNumRecsSent: 1, nNumRecsRqrd: 1, nTxnId: 0, cBtMake: btMake }); + const sndLog = packSndLog({ usrId, opCard, passwd, btId }); + const sendBuf = Buffer.concat([sndHeader, sndLog]); + + const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS); + + const rcvHeader = parseRcvHeaderFlexible(reply.subarray(0, Math.min(reply.length, 24))); + + const json = { + ok: true, + target: `${ABS_HOST}:${ABS_PORT}`, + sentHeaderHex: sndHeader.toString("hex"), + sentBodyHex: sndLog.toString("hex"), + replyBytes: reply.length, + replyHexFirst64: reply.subarray(0, 64).toString("hex"), + rcvHeaderRaw: rcvHeader, + success: rcvHeader.nRetCd === SUCCESS + }; + + if (!givenEnc && encInfo) { + json.encryption = { used: true, utcSeconds: encInfo.utcSeconds, key: encInfo.key, encPasswd: passwd }; + } else if (givenEnc) { + json.encryption = { used: false, providedPasswordEnc: true }; + } + + let bodyOffset = rcvHeader.size; + bodyOffset = adjustForOptionalOp(reply, bodyOffset).offset; + bodyOffset = alignToAsciiYear(reply, bodyOffset); + + json.offsetProbe = { + bodyOffset, + around: reply.subarray(Math.max(0, bodyOffset - 8), Math.min(reply.length, bodyOffset + 48)).toString("ascii") + }; + + if (json.success && reply.length >= bodyOffset + 20) { + try { json.log = parseRcvLog(reply, bodyOffset); } catch (e) { json.parseLogError = String(e); } + } + + res.json(json); + } catch (e) { + res.status(502).json({ ok: false, error: String(e) }); + } +}); + +app.post("/download/btinfo", async (req, res) => { + try { + const body = await readJsonBody(req); + const opCard = (body.opCard ?? "").toString(); + const btId = (body.btId ?? "").toString(); + const usrId = (body.usrId ?? "").toString(); + const plain = (body.password ?? ""); + const encPass = (body.passwordEnc ?? ""); + if (!opCard || !btId || (!plain && !encPass)) { + return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" }); + } + const passwd = encPass ? String(encPass) : encryptPasswordLikeMFC(String(plain)).enc; + + const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: BTI, nNumRecsSent: 1, nNumRecsRqrd: 1, nTxnId: 0, cBtMake: 0 }); + const sndLog = packSndLog({ usrId, opCard, passwd, btId }); + const sendBuf = Buffer.concat([sndHeader, sndLog]); + + const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS); + + const result = { ok: true, replyBytes: reply.length, replyHexFirst64: reply.subarray(0, 64).toString("hex") }; + + let offset = 0; + if (remaining(reply, offset) < 18) return res.json({ ok: false, error: "truncated: no header" }); + + const hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24))); + result.primaryHeader = hdr; + + const hdrLenAligned = (hdr.size + 3) & ~3; + if (!safeAdvanceOrBreak(reply, 0, hdrLenAligned, result)) { + result.error = "truncated: incomplete header (after alignment)"; + return res.json(result); + } + offset = hdrLenAligned; + + if (hdr.nRetCd !== SUCCESS) { + result.error = `server returned nRetCd=${hdr.nRetCd}`; + return res.json(result); + } + + if (!safeAdvanceOrBreak(reply, offset, 5 + 31 + 5 + 4 + 1 + 4 + 4 + 4 + 4, result)) { + result.error = "truncated before BTI master"; + return res.json(result); + } + const btiMst = parseRcvBtiMst(reply, offset); + offset += btiMst.size; + result.btiMst = btiMst; + + const details = []; + let readCount = 0; + while (readCount < hdr.nNumRecs) { + if (looksLikeHeaderAt(reply, offset)) break; + if (!safeAdvanceOrBreak(reply, offset, 1 + 3 + 1, result)) break; + const dt = parseRcvBtiDtl(reply, offset); + offset += dt.size; + details.push(dt); + readCount++; + } + result.btiDetails = details; + result.btiReadCount = readCount; + result.parsedBytes = offset; + + res.json(result); + } catch (e) { + res.status(502).json({ ok: false, error: String(e), stack: e.stack }); + } +}); + +app.post("/download/rpinfo", async (req, res) => { + try { + const body = await readJsonBody(req); + const debug = String((req.query.debug ?? "")).toLowerCase() === "1"; + const opCard = (body.opCard ?? "").toString(); + const btId = (body.btId ?? "").toString(); + const usrId = (body.usrId ?? "").toString(); + const plain = (body.password ?? ""); + const encPass = (body.passwordEnc ?? ""); + + if (!opCard || !btId || (!plain && !encPass)) { + return res.status(400).json({ ok: false, error: "Missing opCard, btId, and either password or passwordEnc" }); + } + + const passwd = encPass ? String(encPass) : encryptPasswordLikeMFC(String(plain)).enc; + const btAdvSalesFlg = body.btAdvSalesFlg || "N"; + const nBtAdvSaleEnb = btAdvSalesFlg === "A" ? 1 : 0; + + const sndHeader = packSndHeader({ nTxnCd: LOG, nOpCd: RPI, nNumRecsSent: 1, nNumRecsRqrd: nBtAdvSaleEnb, nTxnId: 0, cBtMake: 0 }); + const sndLog = packSndLog({ usrId, opCard, passwd, btId }); + const sendBuf = Buffer.concat([sndHeader, sndLog]); + + const reply = await withRetry(() => absRoundtrip(sendBuf, DEFAULT_TIMEOUT_MS), RETRIES, RETRY_DELAY_MS); + + const result = { ok: true, replyBytes: reply.length, replyHexFirst64: reply.subarray(0, 64).toString("hex") }; + let offset = 0; + + if (remaining(reply, offset) < 18) return res.json({ ok: false, error: "truncated: no top header" }); + const topHdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24))); + offset += topHdr.size; + result.topHeader = topHdr; + + if (topHdr.nRetCd !== SUCCESS) { + result.error = `server returned nRetCd=${topHdr.nRetCd}`; + return res.json(result); + } + + if (debug) result.debug_afterTopHdr = { offset, hex: hexSlice(reply, offset, 128) }; + + function parseSection(minRecBytes, recParser, outArrName, outHeaderName, expected_nTxnCd) { + const items = []; + let read = 0; + let hdr = null; + + if (remaining(reply, offset) < 18) { + result[outArrName] = items; + result[outHeaderName] = null; + result[outArrName.replace(/s?$/, "ReadCount")] = read; + if (debug) result[`debug_${outArrName}_no_data`] = `No data for ${outArrName}, offset=${offset}, remaining=${remaining(reply, offset)}`; + return items; + } + + // Try parsing header + offset = alignToPossibleHeader(reply, offset, expected_nTxnCd); + if (looksLikeHeaderAt(reply, offset, expected_nTxnCd)) { + hdr = parseRcvHeaderFlexible(reply.subarray(offset, Math.min(reply.length, offset + 24))); + offset += hdr.size; + const adj = adjustForOptionalOp(reply, offset); + offset = adj.offset; + + if (debug) result[`debug_${outArrName}_header`] = { offset, hdr, hex: hexSlice(reply, offset - hdr.size, 64), expected_nTxnCd }; + + while (read < hdr.nNumRecs && remaining(reply, offset) >= minRecBytes) { + try { + const rec = recParser(reply, offset); + if (rec && rec.nRaceDt >= 20000101 && rec.nRaceDt <= 21001231 && rec.cVenueNum >= 1 && rec.cVenueNum <= MAX_VENUES) { + items.push(rec); + offset += rec.size; + read++; + } else { + offset += 1; + if (debug) result[`debug_${outArrName}_invalid`] = `Invalid record at offset ${offset}: ${JSON.stringify(rec)}`; + } + } catch (e) { + if (debug) result[`debug_${outArrName}_error`] = `Error at offset ${offset}: ${e.message}`; + offset += 1; + break; + } + } + } else { + // Fallback: try parsing data directly if no valid header + if (remaining(reply, offset) >= minRecBytes) { + try { + const rec = recParser(reply, offset); + if (rec && rec.nRaceDt >= 20000101 && rec.nRaceDt <= 21001231 && rec.cVenueNum >= 1 && rec.cVenueNum <= MAX_VENUES) { + items.push(rec); + offset += rec.size; + read++; + if (debug) result[`debug_${outArrName}_fallback`] = `Parsed ${outArrName} data without valid header at offset ${offset - rec.size}`; + } else { + if (debug) result[`debug_${outArrName}_fallback_invalid`] = `Fallback invalid record at offset ${offset}: ${JSON.stringify(rec)}`; + } + } catch (e) { + if (debug) result[`debug_${outArrName}_fallback_error`] = `Fallback parse error at offset ${offset}: ${e.message}`; + } + } + if (debug) result[`debug_${outArrName}_noheader`] = `No valid header for ${outArrName} at offset ${offset}, expected nTxnCd=${expected_nTxnCd}, hex=${hexSlice(reply, offset, 64)}`; + } + + result[outHeaderName] = hdr; + result[outArrName] = items; + result[outArrName.replace(/s?$/, "ReadCount")] = read; + if (debug) result[`debug_${outArrName}_after`] = { offset, hex: hexSlice(reply, offset, 128) }; + return items; + } + + // Parse sections with expected nTxnCd + const sections = [ + { minRecBytes: 4 + 1 + 1 + 15 + 41, parser: parseRcvRpi1, arrName: "rpi1", headerName: "rpi1Header", nTxnCd: 101 }, + { minRecBytes: 4 + 1 + 1 + 25 + 4, parser: parseRcvRpi2, arrName: "rpi2", headerName: "rpi2Header", nTxnCd: 102 }, + { minRecBytes: 4 + 1 + 1 + 9, parser: parseRcvRpi3, arrName: "rpi3", headerName: "rpi3Header", nTxnCd: 103 }, + { minRecBytes: 4 + 1 + 3 + 11 + 1, parser: parseRcvRpi4, arrName: "rpi4", headerName: "rpi4Header", nTxnCd: 104 }, + { minRecBytes: 4 + 4 + 4 + 4 + 4 + 31, parser: parseRcvPvm, arrName: "pvms", headerName: "pvmHeader", nTxnCd: 105 } + ]; + + for (const section of sections) { + parseSection(section.minRecBytes, section.parser, section.arrName, section.headerName, section.nTxnCd); + } + + result.parsedBytes = offset; + + // Build raceCard + const raceCard = []; + for (const v of result.rpi1 || []) { + const raceDate = getDateStr(v.nRaceDt); + const venueId = getVenueIdStr(v.cVenueNum); + const venueSts = v.cVenueSts; + + for (let nRace = 0; nRace < MAX_RACES; nRace++) { + if (v.cRaces[nRace] === "0" || !v.cRaces[nRace]) continue; + const raceNum = (nRace + 1).toString().padStart(2, "0"); + raceCard.push({ + RACE_DATE: raceDate, + RACE_VENUE_ID: venueId, + RACE_NO: raceNum, + STOPBET_STATUS: v.cRaces[nRace] === "C" ? "S" : v.cRaces[nRace], + RACE_STATUS: v.cRaces[nRace] === "C" ? "C" : "V", + VENUE_STATUS: venueSts, + START_TIME: "", + ADVT: v.cAdvertisement, + horses: [], + pools: [] + }); + } + } + + for (const r of result.rpi2 || []) { + const raceDate = getDateStr(r.nRaceDt); + const venueId = getVenueIdStr(r.cVenueNum); + const raceNum = r.cRaceNum.toString().padStart(2, "0"); + + for (let nHorse = 0; nHorse < MAX_HORSES; nHorse++) { + if (r.cHorses[nHorse] === "0" || !r.cHorses[nHorse]) continue; + const horseNum = (nHorse + 1).toString().padStart(2, "0"); + const entry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === raceNum); + if (entry) { + entry.horses.push({ + RACE_HORSE_NO: horseNum, + HORSE_STATUS: r.cHorses[nHorse], + REFUND_FILE_STATUS: "" + }); + if (!entry.START_TIME) { + entry.START_TIME = getTimeStr(r.nRaceStartTm); + } + } + } + } + + for (const p of result.rpi3 || []) { + const raceDate = getDateStr(p.nRaceDt); + const venueId = getVenueIdStr(p.cVenueNum); + const raceNum = p.cRaceNum.toString().padStart(2, "0"); + + for (let nPoolId = 0; nPoolId < MAX_POOLS; nPoolId++) { + if (p.cPools[nPoolId] === "0" || !p.cPools[nPoolId]) continue; + const entry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === raceNum); + if (entry) { + entry.pools.push({ + POOL_ID: getPoolIdStr(nPoolId + 1), + POOL_STATUS: p.cPools[nPoolId], + BETTING_CENTRE_ID: "", + SELECT_RACE_NO: "" + }); + } + } + } + + for (const s of result.rpi4 || []) { + const raceDate = getDateStr(s.nRaceDt); + const venueId = getVenueIdStr(s.cVenueNum); + const raceNum = s.cRaceNum; + const venueSts = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId)?.VENUE_STATUS || "V"; + + let entry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === raceNum); + if (!entry) { + entry = { + RACE_DATE: raceDate, + RACE_VENUE_ID: venueId, + RACE_NO: raceNum, + STOPBET_STATUS: "", + RACE_STATUS: s.cPoolSts, + VENUE_STATUS: venueSts, + START_TIME: "", + ADVT: "", + horses: [], + pools: [] + }; + raceCard.push(entry); + } + + entry.pools.push({ + POOL_ID: getMultiLegPoolIdStr(s.cRaceNum), + POOL_STATUS: s.cPoolSts, + BETTING_CENTRE_ID: "", + SELECT_RACE_NO: s.cSelectRaceNum + }); + } + + for (const p of result.rpi3 || []) { + if (!p.cPools.includes("TBP") && !p.cPools.includes("MJP") && !p.cPools.includes("JPP")) continue; + const raceDate = getDateStr(p.nRaceDt); + const venueId = getVenueIdStr(p.cVenueNum); + const raceNum = p.cRaceNum.toString().padStart(2, "0"); + + for (const s of result.rpi4 || []) { + if (s.cSelectRaceNum && s.nRaceDt === p.nRaceDt && s.cVenueNum === p.cVenueNum) { + const selectRaceNum = s.cSelectRaceNum.slice(0, 2); + const selectEntry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === selectRaceNum); + const targetEntry = raceCard.find(e => e.RACE_DATE === raceDate && e.RACE_VENUE_ID === venueId && e.RACE_NO === raceNum); + if (selectEntry && targetEntry) { + targetEntry.STOPBET_STATUS = selectEntry.STOPBET_STATUS; + } + } + } + } + + const pvmData = (result.pvms || []).map(p => ({ + RACE_VENUE: p.cRaceVenue, + POOL_ID: p.cPoolId, + UNIT_PRICE: p.fUnitPrice, + TAX_RATE: p.fTaxRate, + DEDUCT_RATE: p.fDeductRate, + REMARKS: p.cRemarks + })); + + result.raceCard = raceCard; + result.pvm = pvmData; + + res.json(result); + } catch (e) { + res.status(502).json({ ok: false, error: String(e), stack: e.stack }); + } +}); + +const HTTP_PORT = Number(process.env.HTTP_BRIDGE_PORT || 8080); +app.listen(HTTP_PORT, () => { + console.log(`ABS HTTP bridge listening on :${HTTP_PORT}`); + console.log(`Target ABS server: ${ABS_HOST}:${ABS_PORT}`); +});