Skip to content

Commit 97f4828

Browse files
committed
feature: enhanced stats
0 parents  commit 97f4828

24 files changed

Lines changed: 1467 additions & 0 deletions

.air.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
root = "."
2+
tmp_dir = "tmp"
3+
4+
[build]
5+
cmd = "go build -o ./tmp/demo-parser ./cmd/server"
6+
bin = "./tmp/demo-parser server"
7+
include_ext = ["go"]
8+
exclude_dir = ["tmp", "bin", ".git"]
9+
delay = 500
10+
stop_on_error = true
11+
12+
[log]
13+
time = false
14+
15+
[misc]
16+
clean_on_exit = true

.github/workflows/build.yaml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: Build
2+
3+
on:
4+
push:
5+
branches:
6+
- "main"
7+
workflow_dispatch:
8+
9+
jobs:
10+
build-push-and-release:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Set up Docker Buildx
16+
uses: docker/setup-buildx-action@v3
17+
18+
- name: Login to GitHub Container Registry
19+
uses: docker/login-action@v3
20+
with:
21+
registry: ghcr.io
22+
username: ${{ github.actor }}
23+
password: ${{ secrets.GITHUB_TOKEN }}
24+
25+
- name: Build and Push Docker image
26+
uses: docker/build-push-action@v6
27+
with:
28+
push: true
29+
tags: |
30+
ghcr.io/${{ github.repository_owner }}/demo-parser:latest
31+
ghcr.io/${{ github.repository_owner }}/demo-parser:${{ github.sha }}
32+
cache-from: |
33+
type=registry,ref=ghcr.io/${{ github.repository_owner }}/demo-parser:buildcache
34+
cache-to: |
35+
type=registry,ref=ghcr.io/${{ github.repository_owner }}/demo-parser:buildcache,mode=max
36+
37+
- name: Delete Package Versions
38+
uses: actions/delete-package-versions@v5
39+
with:
40+
package-name: demo-parser
41+
package-type: container
42+
min-versions-to-keep: 8
43+
ignore-versions: '^buildcache-*'

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/tmp/
2+
/bin/
3+
/server
4+
*.dem
5+
.DS_Store

Dockerfile

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# =============================================================================
2+
# demo-parser — standalone, versioned image.
3+
#
4+
# Wraps markus-wa/demoinfocs-golang. Publishes a tiny static binary at
5+
# /usr/local/bin/demo-parser and treats that path as the contract.
6+
# Consumed by api/Dockerfile via:
7+
#
8+
# ARG DEMO_PARSER_IMAGE=ghcr.io/5stackgg/demo-parser:latest
9+
# FROM ${DEMO_PARSER_IMAGE} AS demo-parser
10+
# ...
11+
# COPY --from=demo-parser /usr/local/bin/demo-parser /usr/local/bin/demo-parser
12+
#
13+
# Same pattern as game-streamer/openhud — keeps the parser versioned
14+
# independently of the api so we don't have to vendor Go source into
15+
# the api repo / build context.
16+
#
17+
# Build & publish:
18+
# ./push-latest.sh # tags :latest
19+
# DEMO_PARSER_REF=v0.1.0 ./push-latest.sh
20+
#
21+
# Pin in api with:
22+
# docker build --build-arg DEMO_PARSER_IMAGE=ghcr.io/5stackgg/demo-parser:v0.1.0 .
23+
#
24+
# Standalone HTTP mode (also still works for ad-hoc testing):
25+
# docker run --rm -p 8080:8080 ghcr.io/5stackgg/demo-parser server
26+
#
27+
# CLI mode (what the api uses):
28+
# cat demo.dem | docker run --rm -i ghcr.io/5stackgg/demo-parser parse
29+
# =============================================================================
30+
31+
# syntax=docker/dockerfile:1.7
32+
33+
# ---- Stage 1: build the Go binary -----------------------------------------
34+
FROM golang:1.24-alpine AS build
35+
36+
WORKDIR /src
37+
COPY go.mod go.sum* ./
38+
RUN go mod download
39+
40+
COPY . .
41+
42+
# CGO disabled: produces a fully static binary that runs on alpine /
43+
# scratch / debian / busybox / wherever the consumer wants to drop it.
44+
# -trimpath strips local file paths from the binary; -s -w strip
45+
# debug + symbol tables (~30% size savings, no runtime impact).
46+
RUN CGO_ENABLED=0 go build \
47+
-trimpath \
48+
-ldflags="-s -w" \
49+
-o /out/demo-parser \
50+
./cmd/server
51+
52+
# ---- Stage 2: minimal runtime + ship the binary at a stable path ----------
53+
# distroless/static is just libc + tzdata + ca-certificates; no shell,
54+
# no package manager. The binary is the entire user-space. ~25MB image,
55+
# minimal attack surface.
56+
FROM gcr.io/distroless/static-debian12
57+
58+
# /usr/local/bin/demo-parser is the contract: api/Dockerfile copies
59+
# from this exact path. Don't change it without coordinating both
60+
# build pipelines.
61+
COPY --from=build /out/demo-parser /usr/local/bin/demo-parser
62+
63+
EXPOSE 8080
64+
ENTRYPOINT ["/usr/local/bin/demo-parser"]
65+
# Default to `server` (HTTP) so `docker run` without args still does
66+
# something useful for testing. The api invokes `parse` explicitly.
67+
CMD ["server"]

Dockerfile.dev

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
FROM golang:1.24-alpine
2+
3+
RUN apk add --no-cache git bash
4+
5+
WORKDIR /opt/5stack
6+
7+
COPY go.mod go.sum* ./
8+
RUN go mod download
9+
10+
COPY . .
11+
12+
EXPOSE 8080
13+
14+
CMD ["./scripts/dev.sh"]

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
## 5Stack Demo Parser
2+
3+
5Stack is a platform for organizing and managing competitive CS2 matches and tournaments.
4+
5+
The demo parser is a small HTTP + CLI service that wraps [markus-wa/demoinfocs-golang](https://github.com/markus-wa/demoinfocs-golang) to extract playback metadata, events, and player stats from a CS2 `.dem` file.
6+
7+
Please visit [5Stack](https://docs.5stack.gg) for more documentation.

cmd/server/cli.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"log"
6+
"os"
7+
"time"
8+
9+
"github.com/5stackgg/demo-parser/internal/parser"
10+
)
11+
12+
// runCLI reads a .dem from stdin and writes a Result JSON to stdout.
13+
// Logs go to stderr so they don't pollute the JSON stream.
14+
func runCLI() {
15+
log.SetOutput(os.Stderr)
16+
log.SetFlags(0)
17+
log.SetPrefix("[demo-parser] ")
18+
19+
start := time.Now()
20+
result, err := parser.Parse(os.Stdin)
21+
if err != nil {
22+
log.Printf("parse error: %v", err)
23+
os.Exit(1)
24+
}
25+
log.Printf(
26+
"parsed in %s: ticks=%d tick_rate=%.1f rounds=%d kills=%d bombs=%d "+
27+
"shots=%d damages=%d spotted=%d nades_thrown=%d nades_detonated=%d map=%s",
28+
time.Since(start),
29+
result.TotalTicks, result.TickRate,
30+
len(result.RoundTicks), len(result.Kills), len(result.Bombs),
31+
len(result.ShotsFired), len(result.Damages), len(result.Spotted),
32+
len(result.GrenadeThrows), len(result.GrenadeDetonations),
33+
result.MapName,
34+
)
35+
if err := json.NewEncoder(os.Stdout).Encode(result); err != nil {
36+
log.Printf("encode result: %v", err)
37+
os.Exit(1)
38+
}
39+
}

cmd/server/http.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"log"
8+
"mime/multipart"
9+
"net/http"
10+
"os"
11+
"time"
12+
13+
"github.com/5stackgg/demo-parser/internal/parser"
14+
)
15+
16+
type parseRequest struct {
17+
MatchMapDemoID string `json:"match_map_demo_id"`
18+
DemoURL string `json:"demo_url"`
19+
}
20+
21+
func runServer() {
22+
addr := os.Getenv("LISTEN_ADDR")
23+
if addr == "" {
24+
addr = ":8080"
25+
}
26+
27+
mux := http.NewServeMux()
28+
mux.HandleFunc("/health", handleHealth)
29+
mux.HandleFunc("/parse", handleParse)
30+
mux.HandleFunc("/parse-file", handleParseFile)
31+
32+
srv := &http.Server{
33+
Addr: addr,
34+
Handler: mux,
35+
ReadHeaderTimeout: 10 * time.Second,
36+
WriteTimeout: 5 * time.Minute,
37+
}
38+
log.Printf("demo-parser listening on %s", addr)
39+
if err := srv.ListenAndServe(); err != nil {
40+
log.Fatalf("server: %v", err)
41+
}
42+
}
43+
44+
func handleHealth(w http.ResponseWriter, _ *http.Request) {
45+
w.WriteHeader(http.StatusOK)
46+
_, _ = w.Write([]byte("ok"))
47+
}
48+
49+
// handleParse fetches a demo from a (typically pre-signed) URL and
50+
// returns its parsed Result as JSON.
51+
func handleParse(w http.ResponseWriter, r *http.Request) {
52+
if r.Method != http.MethodPost {
53+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
54+
return
55+
}
56+
var req parseRequest
57+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
58+
http.Error(w, fmt.Sprintf("bad request: %v", err), http.StatusBadRequest)
59+
return
60+
}
61+
if req.DemoURL == "" {
62+
http.Error(w, "demo_url required", http.StatusBadRequest)
63+
return
64+
}
65+
66+
logPrefix := req.MatchMapDemoID
67+
if logPrefix == "" {
68+
logPrefix = "<no-id>"
69+
}
70+
log.Printf("[%s] fetching demo", logPrefix)
71+
72+
demoResp, err := http.Get(req.DemoURL)
73+
if err != nil {
74+
http.Error(w, fmt.Sprintf("fetch demo: %v", err), http.StatusBadGateway)
75+
return
76+
}
77+
defer demoResp.Body.Close()
78+
if demoResp.StatusCode >= 400 {
79+
http.Error(w,
80+
fmt.Sprintf("fetch demo: upstream %d", demoResp.StatusCode),
81+
http.StatusBadGateway,
82+
)
83+
return
84+
}
85+
86+
parseAndRespond(w, demoResp.Body, logPrefix)
87+
}
88+
89+
// handleParseFile is the pre-upload entry point used by a game-server
90+
// node that has the .dem on local disk. The .dem streams in via
91+
// multipart/form-data on the "demo" field — never buffered to memory
92+
// or disk.
93+
func handleParseFile(w http.ResponseWriter, r *http.Request) {
94+
if r.Method != http.MethodPost {
95+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
96+
return
97+
}
98+
99+
mr, err := r.MultipartReader()
100+
if err != nil {
101+
http.Error(w, fmt.Sprintf("bad multipart: %v", err), http.StatusBadRequest)
102+
return
103+
}
104+
105+
var (
106+
filePart *multipart.Part
107+
fileName string
108+
)
109+
for {
110+
part, err := mr.NextPart()
111+
if err == io.EOF {
112+
break
113+
}
114+
if err != nil {
115+
http.Error(w, fmt.Sprintf("read part: %v", err), http.StatusBadRequest)
116+
return
117+
}
118+
if part.FormName() == "demo" {
119+
filePart = part
120+
fileName = part.FileName()
121+
break
122+
}
123+
_ = part.Close()
124+
}
125+
126+
if filePart == nil {
127+
http.Error(w, "missing 'demo' form field", http.StatusBadRequest)
128+
return
129+
}
130+
defer filePart.Close()
131+
132+
logPrefix := fileName
133+
if logPrefix == "" {
134+
logPrefix = "<no-name>"
135+
}
136+
log.Printf("[%s] parsing uploaded demo", logPrefix)
137+
138+
parseAndRespond(w, filePart, logPrefix)
139+
}
140+
141+
func parseAndRespond(w http.ResponseWriter, body io.Reader, logPrefix string) {
142+
start := time.Now()
143+
result, err := parser.Parse(body)
144+
if err != nil {
145+
log.Printf("[%s] parse error: %v", logPrefix, err)
146+
http.Error(w, fmt.Sprintf("parse: %v", err), http.StatusUnprocessableEntity)
147+
return
148+
}
149+
log.Printf(
150+
"[%s] parsed in %s: ticks=%d tick_rate=%.1f rounds=%d map=%s",
151+
logPrefix, time.Since(start),
152+
result.TotalTicks, result.TickRate, len(result.RoundTicks), result.MapName,
153+
)
154+
155+
w.Header().Set("Content-Type", "application/json")
156+
w.WriteHeader(http.StatusOK)
157+
if err := json.NewEncoder(w).Encode(result); err != nil {
158+
log.Printf("[%s] response encode: %v", logPrefix, err)
159+
}
160+
}

cmd/server/main.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// demo-parser is a CS2 .dem file parser with two modes:
2+
//
3+
// demo-parser parse reads .dem bytes from stdin and writes a
4+
// Result JSON to stdout. Exits non-zero on
5+
// parse error.
6+
//
7+
// demo-parser server HTTP service with two endpoints:
8+
// POST /parse — JSON {demo_url} body;
9+
// server fetches the demo.
10+
// POST /parse-file — multipart upload with a
11+
// "demo" file part.
12+
//
13+
// Default (no arg) is `server` for backwards compatibility with old
14+
// docker images that ENTRYPOINT'd the binary directly.
15+
package main
16+
17+
import (
18+
"fmt"
19+
"os"
20+
)
21+
22+
func main() {
23+
mode := "server"
24+
if len(os.Args) > 1 {
25+
mode = os.Args[1]
26+
}
27+
switch mode {
28+
case "parse":
29+
runCLI()
30+
case "server":
31+
runServer()
32+
default:
33+
fmt.Fprintf(os.Stderr, "unknown subcommand: %s (want 'parse' or 'server')\n", mode)
34+
os.Exit(2)
35+
}
36+
}

0 commit comments

Comments
 (0)