Skip to content

Commit b035e03

Browse files
authored
feature: POST /discover proposes smoke throws onto a spot, and POST /floor (#12)
1 parent f512ace commit b035e03

13 files changed

Lines changed: 4538 additions & 2 deletions

File tree

‎cmd/server/discover.go‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"log"
8+
"net/http"
9+
"runtime"
10+
"strings"
11+
12+
"github.com/5stackgg/demo-parser/internal/discover"
13+
"github.com/5stackgg/demo-parser/internal/geometry"
14+
"github.com/5stackgg/demo-parser/internal/simulate"
15+
)
16+
17+
// POST /discover — throws that land a grenade on a chosen spot.
18+
//
19+
// Given a map, a target and an area to throw from, search the techniques and
20+
// mouse buttons asked for and return the throws the fitted flight model lands
21+
// on the target, easiest first. They are predictions for a game server to
22+
// throw for real; see package discover.
23+
24+
const maxDiscoverBody = 64 << 10
25+
26+
// discoverConcurrency bounds how many searches run at once. A search keeps
27+
// every core it is given busy for its whole budget, so two at once only makes
28+
// both slower; the API queues them anyway.
29+
func discoverConcurrency() int {
30+
if n, ok := envInt("DISCOVER_CONCURRENCY"); ok && n > 0 {
31+
return n
32+
}
33+
return 1
34+
}
35+
36+
func discoverWorkers() int {
37+
if n, ok := envInt("DISCOVER_WORKERS"); ok && n > 0 {
38+
return n
39+
}
40+
return min(runtime.NumCPU(), 8)
41+
}
42+
43+
var discoverSlots = make(chan struct{}, max(1, discoverConcurrency()))
44+
45+
func handleDiscover(w http.ResponseWriter, r *http.Request) {
46+
if r.Method != http.MethodPost {
47+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
48+
return
49+
}
50+
var req discover.Request
51+
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxDiscoverBody))
52+
if err := dec.Decode(&req); err != nil {
53+
http.Error(w, fmt.Sprintf("bad request: %v", err), http.StatusBadRequest)
54+
return
55+
}
56+
if strings.TrimSpace(req.Map) == "" {
57+
http.Error(w, "map is required", http.StatusBadRequest)
58+
return
59+
}
60+
if err := req.Validate(); err != nil {
61+
http.Error(w, err.Error(), http.StatusBadRequest)
62+
return
63+
}
64+
if _, err := geometry.ResolveMeshRevision(req.Revision); err != nil {
65+
http.Error(w, fmt.Sprintf("revision: %v", err), http.StatusBadRequest)
66+
return
67+
}
68+
69+
select {
70+
case discoverSlots <- struct{}{}:
71+
defer func() { <-discoverSlots }()
72+
case <-r.Context().Done():
73+
return
74+
}
75+
76+
world, err := grenadeWorld(req.Map, req.Revision)
77+
if err != nil {
78+
var upstream errUpstream
79+
switch {
80+
case errors.Is(err, simulate.ErrNoMesh):
81+
http.Error(w, fmt.Sprintf("no collision mesh for %s", req.Map), http.StatusNotFound)
82+
case errors.As(err, &upstream):
83+
http.Error(w, err.Error(), http.StatusBadGateway)
84+
default:
85+
http.Error(w, err.Error(), http.StatusInternalServerError)
86+
}
87+
return
88+
}
89+
90+
res, err := discover.Search(r.Context(), world, req, discoverWorkers())
91+
if err != nil {
92+
http.Error(w, err.Error(), http.StatusBadRequest)
93+
return
94+
}
95+
log.Printf("[discover] %s: %d stands, %d flights, %d candidates%s",
96+
req.Map, res.Stands, res.Flights, len(res.Candidates), map[bool]string{true: " (budget ran out)", false: ""}[res.Truncated])
97+
writeJSON(w, res)
98+
}
99+
100+
// POST /floor — the floor under a point nearest the height given for it, for a
101+
// caller that picked the point off a radar and has only a guess at the height.
102+
103+
type floorRequest struct {
104+
Map string `json:"map"`
105+
Revision string `json:"revision,omitempty"`
106+
X float64 `json:"x"`
107+
Y float64 `json:"y"`
108+
Z float64 `json:"z"`
109+
}
110+
111+
type floorResponse struct {
112+
Found bool `json:"found"`
113+
Z float64 `json:"z"`
114+
}
115+
116+
func handleFloor(w http.ResponseWriter, r *http.Request) {
117+
var req floorRequest
118+
if !decodeGeometryRequest(w, r, &req) {
119+
return
120+
}
121+
if strings.TrimSpace(req.Map) == "" {
122+
http.Error(w, "map is required", http.StatusBadRequest)
123+
return
124+
}
125+
if _, err := geometry.ResolveMeshRevision(req.Revision); err != nil {
126+
http.Error(w, fmt.Sprintf("revision: %v", err), http.StatusBadRequest)
127+
return
128+
}
129+
world, err := grenadeWorld(req.Map, req.Revision)
130+
if err != nil {
131+
var upstream errUpstream
132+
switch {
133+
case errors.Is(err, simulate.ErrNoMesh):
134+
http.Error(w, fmt.Sprintf("no collision mesh for %s", req.Map), http.StatusNotFound)
135+
case errors.As(err, &upstream):
136+
http.Error(w, err.Error(), http.StatusBadGateway)
137+
default:
138+
http.Error(w, err.Error(), http.StatusInternalServerError)
139+
}
140+
return
141+
}
142+
z, found := discover.FloorNear(world, req.X, req.Y, req.Z)
143+
writeJSON(w, floorResponse{Found: found, Z: z})
144+
}

‎cmd/server/discover_test.go‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/5stackgg/demo-parser/internal/discover"
10+
"github.com/golang/geo/r3"
11+
)
12+
13+
func openGround(t *testing.T) string {
14+
t.Helper()
15+
return synthRevision(t, map[string][][9]float64{
16+
"de_clip.tri.gz": {
17+
{-3000, -3000, 0, 3000, -3000, 0, 3000, 3000, 0},
18+
{-3000, -3000, 0, 3000, 3000, 0, -3000, 3000, 0},
19+
},
20+
})
21+
}
22+
23+
func discoverRequest(revision string) discover.Request {
24+
return discover.Request{
25+
Map: "de_clip",
26+
Revision: revision,
27+
Target: r3.Vector{X: 900, Y: 300},
28+
Radius: 24,
29+
Area: discover.Area{Min: r3.Vector{X: -64, Y: -64, Z: -32}, Max: r3.Vector{X: 64, Y: 64, Z: 32}},
30+
Techniques: []discover.Technique{{}, {JumpThrow: true}},
31+
Strengths: []discover.Strength{discover.Full},
32+
BudgetMs: 20000,
33+
}
34+
}
35+
36+
func TestDiscoverEndpointFindsThrows(t *testing.T) {
37+
t.Setenv("MAP_MESH_CACHE", "4")
38+
w := post(t, handleDiscover, discoverRequest(openGround(t)))
39+
if w.Code != http.StatusOK {
40+
t.Fatalf("status %d: %s", w.Code, w.Body.String())
41+
}
42+
var res discover.Response
43+
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
44+
t.Fatal(err)
45+
}
46+
if len(res.Candidates) == 0 || res.Truncated {
47+
t.Fatalf("%d candidates, truncated %v", len(res.Candidates), res.Truncated)
48+
}
49+
for _, c := range res.Candidates {
50+
if c.Distance > 24 || c.Name == "" {
51+
t.Fatalf("candidate %+v", c)
52+
}
53+
}
54+
}
55+
56+
func TestDiscoverEndpointRejectsBadRequests(t *testing.T) {
57+
t.Setenv("MAP_MESH_CDN", "")
58+
cases := map[string]func(*discover.Request){
59+
"no map": func(r *discover.Request) { r.Map = "" },
60+
"no technique": func(r *discover.Request) { r.Techniques = nil },
61+
"no radius": func(r *discover.Request) { r.Radius = 0 },
62+
"escaping revision": func(r *discover.Request) { r.Revision = "../../../etc" },
63+
}
64+
for name, mutate := range cases {
65+
t.Run(name, func(t *testing.T) {
66+
req := discoverRequest("")
67+
mutate(&req)
68+
if w := post(t, handleDiscover, req); w.Code != http.StatusBadRequest {
69+
t.Fatalf("status %d: %s", w.Code, w.Body.String())
70+
}
71+
})
72+
}
73+
}
74+
75+
func TestDiscoverEndpoint404sAMapWithNoMesh(t *testing.T) {
76+
t.Setenv("MAP_MESH_CACHE", "4")
77+
req := discoverRequest(openGround(t))
78+
req.Map = "de_nonexistent"
79+
if w := post(t, handleDiscover, req); w.Code != http.StatusNotFound {
80+
t.Fatalf("status %d: %s", w.Code, w.Body.String())
81+
}
82+
}
83+
84+
func TestDiscoverEndpointRejectsNonPost(t *testing.T) {
85+
w := httptest.NewRecorder()
86+
handleDiscover(w, httptest.NewRequest(http.MethodGet, "/discover", nil))
87+
if w.Code != http.StatusMethodNotAllowed {
88+
t.Fatalf("status %d", w.Code)
89+
}
90+
}
91+
92+
func TestDiscoverEndpointWritesLowercasePoints(t *testing.T) {
93+
t.Setenv("MAP_MESH_CACHE", "4")
94+
w := post(t, handleDiscover, discoverRequest(openGround(t)))
95+
if w.Code != http.StatusOK {
96+
t.Fatalf("status %d: %s", w.Code, w.Body.String())
97+
}
98+
var res struct {
99+
Candidates []map[string]any `json:"candidates"`
100+
}
101+
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
102+
t.Fatal(err)
103+
}
104+
if len(res.Candidates) == 0 {
105+
t.Fatal("no candidates")
106+
}
107+
for _, field := range []string{"stand", "eye", "position", "velocity", "landing"} {
108+
v, ok := res.Candidates[0][field].(map[string]any)
109+
if !ok {
110+
t.Fatalf("%s is %T", field, res.Candidates[0][field])
111+
}
112+
for _, axis := range []string{"x", "y", "z"} {
113+
if _, ok := v[axis].(float64); !ok {
114+
t.Fatalf("%s has no %q: %v", field, axis, v)
115+
}
116+
}
117+
}
118+
}
119+
120+
// A spot picked off a radar has no real height. Snapped, a guess 90 units off
121+
// still finds the throws onto the floor there; unsnapped it finds none.
122+
func TestDiscoverEndpointSnapsAGuessedHeightToTheFloor(t *testing.T) {
123+
t.Setenv("MAP_MESH_CACHE", "4")
124+
revision := openGround(t)
125+
126+
req := discoverRequest(revision)
127+
req.Target.Z = 90
128+
req.Area.Min.Z, req.Area.Max.Z = -200, 200
129+
130+
w := post(t, handleDiscover, req)
131+
if w.Code != http.StatusOK {
132+
t.Fatalf("status %d: %s", w.Code, w.Body.String())
133+
}
134+
var unsnapped discover.Response
135+
if err := json.Unmarshal(w.Body.Bytes(), &unsnapped); err != nil {
136+
t.Fatal(err)
137+
}
138+
if len(unsnapped.Candidates) != 0 {
139+
t.Fatalf("unsnapped, %d candidates land 90 units under the target", len(unsnapped.Candidates))
140+
}
141+
142+
req.Snap = true
143+
w = post(t, handleDiscover, req)
144+
if w.Code != http.StatusOK {
145+
t.Fatalf("status %d: %s", w.Code, w.Body.String())
146+
}
147+
var res struct {
148+
Candidates []map[string]any `json:"candidates"`
149+
Target struct{ X, Y, Z float64 }
150+
}
151+
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
152+
t.Fatal(err)
153+
}
154+
if len(res.Candidates) == 0 || res.Target.Z != 0 {
155+
t.Fatalf("snapped: %d candidates, target z %v", len(res.Candidates), res.Target.Z)
156+
}
157+
}
158+
159+
func TestFloorEndpoint(t *testing.T) {
160+
t.Setenv("MAP_MESH_CACHE", "4")
161+
revision := openGround(t)
162+
163+
w := post(t, handleFloor, floorRequest{Map: "de_clip", Revision: revision, X: 10, Y: 10, Z: 64})
164+
if w.Code != http.StatusOK {
165+
t.Fatalf("status %d: %s", w.Code, w.Body.String())
166+
}
167+
var res floorResponse
168+
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
169+
t.Fatal(err)
170+
}
171+
if !res.Found || res.Z != 0 {
172+
t.Fatalf("floor %+v, want found at 0", res)
173+
}
174+
175+
w = post(t, handleFloor, floorRequest{Map: "de_clip", Revision: revision, X: 9000, Y: 9000, Z: 0})
176+
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil || res.Found {
177+
t.Fatalf("off the map: %+v %v", res, err)
178+
}
179+
}

‎cmd/server/http.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ func runServer() {
3939
mux.HandleFunc("/sightlines", handleSightlines)
4040
mux.HandleFunc("/oneway", handleOneway)
4141
mux.HandleFunc("/drift", handleDrift)
42+
mux.HandleFunc("/discover", handleDiscover)
43+
mux.HandleFunc("/floor", handleFloor)
4244

4345
srv := &http.Server{
4446
Addr: addr,

‎cmd/server/main.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
// POST /oneway — asymmetric visibility through
1515
// a smoke.
1616
// POST /drift — which lineups a map update moved.
17+
// POST /discover — throws that land on a spot.
18+
// POST /floor — the floor under a picked point.
1719
//
18-
// The last four answer questions about a map rather than a demo, for the
19-
// lineup library; see cmd/server/nades.go and cmd/server/drift.go.
20+
// The last six answer questions about a map rather than a demo, for the
21+
// lineup library; see cmd/server/nades.go, drift.go and discover.go.
2022
//
2123
// Default (no arg) is `server` for backwards compatibility with old
2224
// docker images that ENTRYPOINT'd the binary directly.

0 commit comments

Comments
 (0)