diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..1078294
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,23 @@
+services:
+ biggame-xml-proxy:
+ image: node:20-alpine
+ container_name: biggame-xml-proxy
+ working_dir: /app
+ volumes:
+ - ./:/app
+ command: sh -c "npm install --silent && node server.js"
+ ports:
+ - "3737:3737"
+ environment:
+ BG_BEARER_TOKEN: "bg_live_k_K1zO7Jh-fMkRttp8.uoD4OzE3L_SrLXu0tTsS_4nDdL8DkhzRV6eOSXSkivY"
+ BG_EVENT_ID: "1"
+ BG_RANKING: "stableford_gross"
+ POLL_INTERVAL_MS: "10000"
+ STALE_THRESHOLD_MS: "60000"
+ PORT: "3737"
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost:3737/ping"]
+ interval: 15s
+ timeout: 5s
+ retries: 3
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..4d840b8
--- /dev/null
+++ b/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "biggame-xml-proxy",
+ "version": "1.0.0",
+ "description": "JSON-to-XML proxy bridging Big Game Golf API to Ross XPression DataLinq",
+ "main": "server.js",
+ "scripts": { "start": "node server.js" },
+ "dependencies": {
+ "express": "^4.19.2",
+ "fast-xml-parser": "^4.4.1"
+ }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..6b3b72c
--- /dev/null
+++ b/server.js
@@ -0,0 +1,96 @@
+'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);
+});