Skip to content

Commit 8fab6ad

Browse files
authored
Merge pull request #61 from IamMrCupp/feat/rpg-world-map
feat(idlerpg): persistent world map (/map)
2 parents 57e36ee + 00df2b8 commit 8fab6ad

8 files changed

Lines changed: 229 additions & 3 deletions

File tree

docs/idlerpg.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,10 @@ stack). It still needs Redis for game state.
101101

102102
## The web dashboard
103103

104-
A separate read-only service renders the realm as a web page — the top idlers and
105-
the active quest, auto-refreshing. It reads the same Redis the bots write, so it's
106-
just a view; it never touches the game.
104+
A separate read-only service renders the realm as a web page — the top idlers, the
105+
active quest, per-character pages (`/p/<name>`), and a **world map** (`/map`) where
106+
every player wanders as a dot and the towns are marked. It reads the same Redis the
107+
bots write, so it's just a view; it never touches the game. Auto-refreshes.
107108

108109
- **Docker Compose:** it's the `dashboard` service — browse <http://localhost:8080>.
109110
- **Kubernetes:** `kubectl apply -k deploy/k8s/dashboard -n annoybots`, then

internal/idlerpg/idlerpg.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,7 @@ func (m *Manager) Tick() {
544544
for _, p := range m.snapshot() {
545545
ctx := context.Background()
546546
key := sheetKey(p.key)
547+
m.moveOnMap(ctx, p.key) // wander the world map (cosmetic; happens every tick)
547548
ttl, err := m.store.HIncr(ctx, key, "ttl", -step)
548549
if err != nil {
549550
continue

internal/idlerpg/idlerpg_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,20 @@ func TestStatusCommand(t *testing.T) {
512512
}
513513
}
514514

515+
func TestWorldMapMovement(t *testing.T) {
516+
m, _, st := newMgr()
517+
ctx := context.Background()
518+
m.Handle(chanMsg("alice", "!rpg")) // enroll + online
519+
if s, _ := st.HGetAll(ctx, sheetKey("net|alice")); s["mx"] != 0 {
520+
t.Fatal("a fresh player should start unplaced (mx=0)")
521+
}
522+
m.Tick() // moves (places) alice
523+
s, _ := st.HGetAll(ctx, sheetKey("net|alice"))
524+
if s["mx"] < 1 || s["mx"] > worldSize || s["my"] < 1 || s["my"] > worldSize {
525+
t.Fatalf("alice should be placed in-bounds, got (%d,%d)", s["mx"], s["my"])
526+
}
527+
}
528+
515529
func allowAll(engine.Message) bool { return true }
516530

517531
func TestAdminVerbsRequireAuthz(t *testing.T) {

internal/idlerpg/read.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,49 @@ type QuestView struct {
4242
MapSize int
4343
}
4444

45+
// MapDot is a player's position on the world map.
46+
type MapDot struct {
47+
Name string
48+
X, Y int
49+
Level int64
50+
}
51+
52+
// Town is a named landmark on the world map.
53+
type Town struct {
54+
Name string
55+
X, Y int
56+
}
57+
58+
// WorldView is everything the dashboard needs to draw the world map: every placed
59+
// player's position and the static towns.
60+
type WorldView struct {
61+
Players []MapDot
62+
Towns []Town
63+
Size int
64+
}
65+
66+
// ReadWorld returns the world map — up to limit placed players plus the towns.
67+
func ReadWorld(ctx context.Context, store state.Store, limit int) (WorldView, error) {
68+
top, err := store.ZTop(ctx, boardKey(), limit)
69+
if err != nil {
70+
return WorldView{}, err
71+
}
72+
w := WorldView{Size: worldSize}
73+
for _, e := range towns {
74+
w.Towns = append(w.Towns, Town(e))
75+
}
76+
for _, e := range top {
77+
sheet, _ := store.HGetAll(ctx, sheetKey(e.Member))
78+
if sheet["mx"] == 0 || sheet["my"] == 0 {
79+
continue // not placed on the map yet
80+
}
81+
w.Players = append(w.Players, MapDot{
82+
Name: displayName(e.Member), X: int(sheet["mx"]), Y: int(sheet["my"]), Level: sheet["level"],
83+
})
84+
}
85+
return w, nil
86+
}
87+
4588
// ReadLeaderboard returns up to n characters ranked by level (highest first).
4689
func ReadLeaderboard(ctx context.Context, store state.Store, n int) ([]CharView, error) {
4790
top, err := store.ZTop(ctx, boardKey(), n)

internal/idlerpg/read_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,30 @@ func TestReadLeaderboard(t *testing.T) {
4040
}
4141
}
4242

43+
func TestReadWorld(t *testing.T) {
44+
m, _, st := newMgr()
45+
ctx := context.Background()
46+
m.Handle(chanMsg("alice", "!rpg"))
47+
m.Tick() // places alice on the map
48+
49+
w, err := ReadWorld(ctx, st, 50)
50+
if err != nil {
51+
t.Fatal(err)
52+
}
53+
if len(w.Towns) == 0 || w.Size != worldSize {
54+
t.Fatalf("world should carry towns + size, got %d towns size %d", len(w.Towns), w.Size)
55+
}
56+
found := false
57+
for _, p := range w.Players {
58+
if p.Name == "alice" {
59+
found = true
60+
}
61+
}
62+
if !found {
63+
t.Fatalf("alice should be on the map, got %+v", w.Players)
64+
}
65+
}
66+
4367
func TestReadChar(t *testing.T) {
4468
m, _, st := newMgr()
4569
ctx := context.Background()

internal/idlerpg/world.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package idlerpg
2+
3+
import (
4+
"context"
5+
)
6+
7+
// The world map: every player has a persistent position and wanders a step each
8+
// tick while they're online (idlerpg.net's marquee "watch everyone roam" map).
9+
// Positions are 1-based (mx/my fields on the sheet); 0 means "not placed yet", so
10+
// a brand-new or pre-existing character gets dropped somewhere random on its first
11+
// move. This is cosmetic — movement doesn't affect leveling.
12+
13+
const (
14+
worldSize = 500 // the realm is worldSize×worldSize
15+
worldStep = 14 // max distance a player drifts per tick
16+
)
17+
18+
// town is a static, named landmark drawn on the world map for flavor.
19+
type town struct {
20+
Name string
21+
X, Y int
22+
}
23+
24+
// towns are fixed points of interest on the map.
25+
var towns = []town{
26+
{"Idlecrest", 250, 250},
27+
{"Lurk Harbor", 70, 410},
28+
{"Mount AFK", 420, 90},
29+
{"Quietford", 130, 150},
30+
{"The Lag Marsh", 360, 380},
31+
{"Tab-Away Tavern", 200, 60},
32+
}
33+
34+
// moveOnMap drifts an online player one random step, placing them first if they've
35+
// never been on the map. Caller passes the player's character key.
36+
func (m *Manager) moveOnMap(ctx context.Context, key string) {
37+
sheet, err := m.store.HGetAll(ctx, sheetKey(key))
38+
if err != nil {
39+
return
40+
}
41+
x, y := sheet["mx"], sheet["my"]
42+
if x == 0 || y == 0 {
43+
x = int64(m.roll(worldSize) + 1)
44+
y = int64(m.roll(worldSize) + 1)
45+
} else {
46+
x = clampWorld(x + int64(m.roll(2*worldStep+1)-worldStep))
47+
y = clampWorld(y + int64(m.roll(2*worldStep+1)-worldStep))
48+
}
49+
_ = m.store.HSet(ctx, sheetKey(key), "mx", x)
50+
_ = m.store.HSet(ctx, sheetKey(key), "my", y)
51+
}
52+
53+
func clampWorld(v int64) int64 {
54+
if v < 1 {
55+
return 1
56+
}
57+
if v > worldSize {
58+
return worldSize
59+
}
60+
return v
61+
}

internal/rpgweb/rpgweb.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@ import (
1818

1919
const boardSize = 25
2020

21+
const worldDots = 200 // most players to plot on the world map
22+
2123
// Server renders the dashboard from a read-only view of the state store.
2224
type Server struct {
2325
store state.Store
2426
tmpl *template.Template
2527
charTmpl *template.Template
28+
mapTmpl *template.Template
2629
now func() time.Time
2730
}
2831

@@ -43,6 +46,7 @@ func New(store state.Store) *Server {
4346
store: store,
4447
tmpl: template.Must(template.New("index").Funcs(tmplFuncs).Parse(indexTmpl)),
4548
charTmpl: template.Must(template.New("char").Funcs(tmplFuncs).Parse(charTmpl)),
49+
mapTmpl: template.Must(template.New("map").Funcs(tmplFuncs).Parse(mapTmpl)),
4650
now: time.Now,
4751
}
4852
}
@@ -51,6 +55,7 @@ func New(store state.Store) *Server {
5155
func (s *Server) Handler() http.Handler {
5256
mux := http.NewServeMux()
5357
mux.HandleFunc("/", s.index)
58+
mux.HandleFunc("/map", s.worldMap)
5459
mux.HandleFunc("/p/", s.char)
5560
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
5661
w.WriteHeader(http.StatusOK)
@@ -89,6 +94,19 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
8994
_ = s.tmpl.Execute(w, data)
9095
}
9196

97+
// worldMap renders the persistent world map: every placed player + the towns.
98+
func (s *Server) worldMap(w http.ResponseWriter, r *http.Request) {
99+
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
100+
defer cancel()
101+
world, err := idlerpg.ReadWorld(ctx, s.store, worldDots)
102+
if err != nil {
103+
http.Error(w, "the realm is unreachable right now.", http.StatusServiceUnavailable)
104+
return
105+
}
106+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
107+
_ = s.mapTmpl.Execute(w, world)
108+
}
109+
92110
// char renders one character's sheet at /p/<key>.
93111
func (s *Server) char(w http.ResponseWriter, r *http.Request) {
94112
key := strings.TrimPrefix(r.URL.Path, "/p/")
@@ -154,6 +172,7 @@ const indexTmpl = `<!doctype html>
154172
</head>
155173
<body>
156174
<h1>⚔ the idle realm</h1>
175+
<p class="muted"><a href="/map">🗺 the realm map</a> — see where everyone's wandering.</p>
157176
{{if .Quest}}
158177
<div class="quest">
159178
<strong>A quest is underway.</strong>
@@ -240,3 +259,46 @@ const charTmpl = `<!doctype html>
240259
<footer><a href="/">&larr; back to the realm</a></footer>
241260
</body>
242261
</html>`
262+
263+
const mapTmpl = `<!doctype html>
264+
<html lang="en">
265+
<head>
266+
<meta charset="utf-8">
267+
<meta name="viewport" content="width=device-width, initial-scale=1">
268+
<meta http-equiv="refresh" content="30">
269+
<title>annoybots · the realm map</title>
270+
<style>
271+
:root { color-scheme: dark; }
272+
body { background:#0e0f13; color:#d6d8de; font:15px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; margin:0; padding:2rem; }
273+
h1 { font-size:1.4rem; margin:0 0 .25rem; color:#e9b949; }
274+
.sub { color:#8aa0c6; margin:0 0 1rem; }
275+
.world { display:block; width:100%; max-width:680px; margin:0 auto; background:#0b0c10; border:1px solid #2a2f3a; border-radius:8px; }
276+
.town { fill:#4a3c14; stroke:#e9b949; stroke-width:2; }
277+
.town-l { fill:#b9912f; font-size:11px; }
278+
.dot { fill:#7fd1a8; stroke:#0e0f13; stroke-width:1.5; }
279+
.dot-l { fill:#9aa0ac; font-size:9px; }
280+
.muted { color:#6b7280; }
281+
a { color:#7fd1a8; text-decoration:none; }
282+
a:hover { text-decoration:underline; }
283+
footer { margin-top:1.5rem; color:#4b5563; font-size:.8rem; text-align:center; }
284+
</style>
285+
</head>
286+
<body>
287+
<h1>🗺 the realm map</h1>
288+
<p class="sub">{{len .Players}} adventurers roaming · towns in gold</p>
289+
<svg class="world" viewBox="-12 -12 {{add .Size 24}} {{add .Size 24}}" role="img" aria-label="world map of player positions">
290+
<rect x="0" y="0" width="{{.Size}}" height="{{.Size}}" fill="#0b0c10" stroke="#20232b" stroke-width="1"/>
291+
{{range .Towns}}
292+
<rect x="{{add .X -5}}" y="{{add .Y -5}}" width="10" height="10" class="town"/>
293+
<text x="{{add .X 9}}" y="{{add .Y 4}}" class="town-l">{{.Name}}</text>
294+
{{end}}
295+
{{range .Players}}
296+
<circle cx="{{.X}}" cy="{{.Y}}" r="4" class="dot"/>
297+
<text x="{{add .X 6}}" y="{{add .Y 3}}" class="dot-l">{{.Name}}</text>
298+
{{else}}
299+
<text x="{{add .Size -250}}" y="250" class="dot-l">no one has wandered onto the map yet.</text>
300+
{{end}}
301+
</svg>
302+
<footer><a href="/">&larr; back to the realm</a> · auto-refreshes every 30s</footer>
303+
</body>
304+
</html>`

internal/rpgweb/rpgweb_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,26 @@ func TestCharPage(t *testing.T) {
106106
}
107107
}
108108

109+
func TestWorldMapPage(t *testing.T) {
110+
st := state.NewMem()
111+
log := slog.New(slog.NewTextHandler(io.Discard, nil))
112+
m := idlerpg.New(st, noopSender{}, nil, time.Second, time.Second, time.Hour, time.Hour, log)
113+
m.Handle(engine.Message{Network: "net", Channel: "#c", Nick: "alice", Text: "!rpg"})
114+
m.Tick() // places alice on the map
115+
116+
rr := httptest.NewRecorder()
117+
New(st).Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/map", nil))
118+
if rr.Code != http.StatusOK {
119+
t.Fatalf("map status = %d; want 200", rr.Code)
120+
}
121+
body := rr.Body.String()
122+
for _, want := range []string{"<svg", "alice", "Idlecrest", "back to the realm"} {
123+
if !strings.Contains(body, want) {
124+
t.Fatalf("world map missing %q\n%s", want, body)
125+
}
126+
}
127+
}
128+
109129
func TestHealthz(t *testing.T) {
110130
rr := httptest.NewRecorder()
111131
New(state.NewMem()).Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))

0 commit comments

Comments
 (0)