'use strict';
const express = require('express');
const https = require('https');
const { XMLBuilder } = require('fast-xml-parser');
const CONFIG = {
BG_BASE_URL: 'https://api.biggamegolf.com/api/integrations/v1',
BG_BEARER_TOKEN: process.env.BG_BEARER_TOKEN || 'MISSING',
BG_EVENT_ID: process.env.BG_EVENT_ID || '1',
BG_RANKING: process.env.BG_RANKING || 'stableford_gross',
POLL_INTERVAL_MS: parseInt(process.env.POLL_INTERVAL_MS || '10000'),
STALE_THRESHOLD_MS: parseInt(process.env.STALE_THRESHOLD_MS || '60000'),
PORT: parseInt(process.env.PORT || '3737'),
};
const state = { summaryXml:null, playerCache:{}, lastUpdated:null, lastError:null, fetchCount:0, errorCount:0 };
function bgFetch(path) {
return new Promise((resolve,reject) => {
const url=`${CONFIG.BG_BASE_URL}${path}`;
https.get(url,{headers:{Authorization:`Bearer ${CONFIG.BG_BEARER_TOKEN}`,Accept:'application/json'}},(res)=>{
let body='';
res.on('data',(c)=>body+=c);
res.on('end',()=>{
if(res.statusCode===200){try{resolve(JSON.parse(body));}catch(e){reject(new Error(e.message));}}
else{reject(new Error(`HTTP ${res.statusCode}: ${body}`));}
});
}).on('error',reject);
});
}
const builder=new XMLBuilder({ignoreAttributes:false,attributeNamePrefix:'@_',format:true,suppressEmptyNode:true});
function fmtPar(v){if(v===null||v===undefined)return '';if(v===0)return 'E';return v>0?`+${v}`:String(v);}
function isStale(){if(!state.lastUpdated)return true;return(Date.now()-state.lastUpdated.getTime())>CONFIG.STALE_THRESHOLD_MS;}
function buildSummaryXml(data){
const{leaderboard,players}=data;
const now=new Date().toISOString();
const xmlPlayers=players.map((p)=>{
const r1=(p.rounds||[]).find(r=>r.round===1)||{};
const r2=(p.rounds||[]).find(r=>r.round===2)||{};
return{'@_rank':p.standing.position,'@_tied':p.standing.isTied?1:0,'@_sortOrder':p.standing.sortOrder,
'@_playerId':p.playerId,'@_firstName':p.firstName,'@_lastName':p.lastName,'@_displayName':p.displayName,'@_profilePicUrl':p.profilePicUrl||'',
'@_points':p.summary.points,'@_strokes':p.summary.strokes,'@_toPar':p.summary.toPar,'@_toParDisplay':fmtPar(p.summary.toPar),
'@_holesPlayed':p.summary.holesPlayed,'@_thru':p.summary.thru,
'@_r1Points':r1.points??'','@_r1Strokes':r1.strokes??'','@_r1ToPar':r1.toPar??'','@_r1ToParDisplay':fmtPar(r1.toPar),'@_r1Status':r1.status||'','@_r1HolesPlayed':r1.holesPlayed??'',
'@_r2Points':r2.points??'','@_r2Strokes':r2.strokes??'','@_r2ToPar':r2.toPar??'','@_r2ToParDisplay':fmtPar(r2.toPar),'@_r2Status':r2.status||'','@_r2HolesPlayed':r2.holesPlayed??''};
});
const rds=(leaderboard.eventRounds||[]).map(r=>({'@_round':r.round,'@_label':r.label,'@_date':r.date,'@_playedHoles':r.playedHoles,'@_facility':r.course?.facility||'','@_course':r.course?.name||'','@_location':r.course?.location||''}));
const doc={leaderboard:{'@_eventId':leaderboard.eventId,'@_eventName':leaderboard.name,'@_eventStart':leaderboard.eventDates?.start||'','@_eventEnd':leaderboard.eventDates?.end||'','@_ranking':`${leaderboard.ranking?.pointSystem}_${leaderboard.ranking?.handicapMode}`,'@_updated':now,'@_stale':isStale()?1:0,rounds:{round:rds},players:{player:xmlPlayers}}};
return '\n'+builder.build(doc);
}
function buildPlayerXml(data){
const{leaderboard,player,standing,summary,detail}=data;
const now=new Date().toISOString();
const rds=(detail.rounds||[]).map(r=>({'@_round':r.round,'@_label':r.label,'@_date':r.date,'@_status':r.status,'@_points':r.points??'','@_strokes':r.strokes??'','@_toPar':r.toPar??'','@_toParDisplay':fmtPar(r.toPar),'@_holesPlayed':r.holesPlayed??''}));
const scs=(detail.scorecards||[]).map(sc=>{const holes=(sc.holes||[]).map(h=>({'@_hole':h.hole,'@_par':h.par??'','@_strokes':h.strokes??'','@_points':h.points??'','@_scoreX':h.scoreX?1:0}));return{'@_round':sc.round,'@_date':sc.date,'@_pointsOut':sc.points?.out??'','@_pointsIn':sc.points?.in??'','@_pointsTotal':sc.points?.total??'','@_strokesOut':sc.strokes?.out??'','@_strokesIn':sc.strokes?.in??'','@_strokesTotal':sc.strokes?.total??'',hole:holes};});
const doc={playerDetail:{'@_eventId':leaderboard.eventId,'@_eventName':leaderboard.name,'@_updated':now,'@_stale':isStale()?1:0,player:{'@_playerId':player.playerId,'@_firstName':player.firstName,'@_lastName':player.lastName,'@_displayName':player.displayName,'@_profilePicUrl':player.profilePicUrl||'','@_rank':standing.position,'@_tied':standing.isTied?1:0,'@_points':summary.points,'@_strokes':summary.strokes,'@_toPar':summary.toPar,'@_toParDisplay':fmtPar(summary.toPar),'@_holesPlayed':summary.holesPlayed,'@_thru':summary.thru},rounds:{round:rds},scorecards:{scorecard:scs}}};
return '\n'+builder.build(doc);
}
async function pollSummary(){
try{
console.log(`[poll] Fetching event ${CONFIG.BG_EVENT_ID}...`);
const data=await bgFetch(`/leaderboards/${CONFIG.BG_EVENT_ID}/summary/?ranking=${CONFIG.BG_RANKING}`);
state.summaryXml=buildSummaryXml(data);
state.lastUpdated=new Date();
state.lastError=null;
state.fetchCount++;
console.log(`[poll] OK - ${data.players?.length??0} players`);
}catch(err){
state.lastError=err.message;
state.errorCount++;
console.error(`[poll] ERROR: ${err.message}`);
}
}
const app=express();
app.get('/leaderboard',(req,res)=>{
if(!state.summaryXml)return res.status(503).send('not_ready');
res.set('Content-Type','application/xml').send(state.summaryXml);
});
app.get('/player/:id',async(req,res)=>{
const id=req.params.id;
const c=state.playerCache[id];
if(c&&(Date.now()-c.ts)${err.message}`);
}
});
app.get('/status',(req,res)=>res.json({ok:!!state.summaryXml&&!isStale(),stale:isStale(),lastUpdated:state.lastUpdated,lastError:state.lastError,fetchCount:state.fetchCount,errorCount:state.errorCount,eventId:CONFIG.BG_EVENT_ID,cachedPlayers:Object.keys(state.playerCache)}));
app.get('/ping',(req,res)=>res.send('pong'));
app.listen(CONFIG.PORT,()=>{
console.log(`[proxy] Big Game Golf XML Proxy on :${CONFIG.PORT}`);
pollSummary();
setInterval(pollSummary,CONFIG.POLL_INTERVAL_MS);
});