finish aggpop

This commit is contained in:
41666 2024-11-02 00:32:51 -07:00
parent dea89d9a30
commit e47389854f
34 changed files with 1277 additions and 0 deletions

2
.envrc Normal file
View file

@ -0,0 +1,2 @@
dotenv;
use flake;

1
.gitignore vendored
View file

@ -21,3 +21,4 @@
# Go workspace file # Go workspace file
go.work go.work
.direnv/

View file

@ -0,0 +1,57 @@
package aggps2live
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"git.sapphic.engineer/ps2.live/ps2.live/clients"
"git.sapphic.engineer/ps2.live/ps2.live/types"
)
type AggPopClient interface {
GetWorld(ctx context.Context, id string) (*types.AggPopWorld, error)
GetAllWorlds(ctx context.Context) ([]*types.AggPopWorld, error)
}
// Client implements AggClient.
type Client struct {
clients.CommonClient
}
func NewAggPopClient(baseURL ...string) AggPopClient {
u := "https://agg.ps2.live/population"
if len(baseURL) != 0 {
u = baseURL[0]
}
c := &Client{}
c.SetBaseURL(u)
c.SetClient(&http.Client{
Timeout: 30 * time.Second,
})
return c
}
func (c *Client) GetWorld(ctx context.Context, id string) (w *types.AggPopWorld, err error) {
res, err := c.Request(ctx, "/"+id)
if err != nil {
return nil, fmt.Errorf("GetWorld request failed, %w", err)
}
err = json.NewDecoder(res.Body).Decode(&w)
return
}
func (c *Client) GetAllWorlds(ctx context.Context) (ws []*types.AggPopWorld, err error) {
res, err := c.Request(ctx, "/")
if err != nil {
return nil, fmt.Errorf("GetAllWorlds request failed, %w", err)
}
err = json.NewDecoder(res.Body).Decode(&ws)
return
}

49
clients/common.go Normal file
View file

@ -0,0 +1,49 @@
package clients
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
)
type CommonClient struct {
HttpClient *http.Client
BaseURL string
}
func (c *CommonClient) Request(ctx context.Context, path string) (*http.Response, error) {
url, err := url.Parse(c.BaseURL + path)
if err != nil {
return nil, fmt.Errorf("failed to parse URL, %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), http.NoBody)
if err != nil {
return nil, fmt.Errorf("crafting http request failed, %w", err)
}
res, err := c.HttpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("http request failed, %w", err)
}
if res.StatusCode != http.StatusOK {
body, err := io.ReadAll(res.Body)
if err != nil {
body = []byte(fmt.Sprintf("<< unable to read (%s) >>", err))
}
return res, fmt.Errorf("http request not 200, got: %d\nresponse body: %s", res.StatusCode, string(body))
}
return res, nil
}
func (c *CommonClient) SetBaseURL(baseURL string) {
c.BaseURL = baseURL
}
func (c *CommonClient) SetClient(client *http.Client) {
c.HttpClient = client
}

View file

@ -0,0 +1,61 @@
package metagameps2live
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"git.sapphic.engineer/ps2.live/ps2.live/clients"
"git.sapphic.engineer/ps2.live/ps2.live/types"
)
type MetagameClient interface {
GetWorld(ctx context.Context, id string) (*types.MetagameWorld, error)
GetAllWorlds(ctx context.Context) ([]*types.MetagameWorld, error)
}
// Client implements MetagameClient.
type Client struct {
clients.CommonClient
}
type MetagameClientConfig struct {
BaseURL string
}
func NewMetagameClient(baseURL ...string) MetagameClient {
u := "https://metagame.ps2.live"
if len(baseURL) != 0 {
u = baseURL[0]
}
c := &Client{}
c.SetBaseURL(u)
c.SetClient(&http.Client{
Timeout: 30 * time.Second,
})
return c
}
func (c *Client) GetWorld(ctx context.Context, id string) (w *types.MetagameWorld, err error) {
res, err := c.Request(ctx, "/"+id)
if err != nil {
return nil, fmt.Errorf("GetWorld request failed, %w", err)
}
err = json.NewDecoder(res.Body).Decode(&w)
return
}
func (c *Client) GetAllWorlds(ctx context.Context) (ws []*types.MetagameWorld, err error) {
res, err := c.Request(ctx, "/")
if err != nil {
return nil, fmt.Errorf("GetWorld request failed, %w", err)
}
err = json.NewDecoder(res.Body).Decode(&ws)
return
}

View file

@ -0,0 +1,9 @@
# Saerro Go API Client
This only fulfills the goals of rendering the ps2.live website directly.
A consumer may use `saerrops2live.Query(...)` to use GraphQL directly.
## Mocks
a stretchr mock is available at ./mock

View file

@ -0,0 +1,129 @@
package saerrops2live
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"git.sapphic.engineer/ps2.live/ps2.live/types"
)
// SaerroClient connects to https://saerro.ps2.live and returns some results from its GraphQL API.
// btw this is mockable in stretchr :>
type SaerroClient interface {
Query(ctx context.Context, query string, out interface{}) error
GetWorldPopulation(ctx context.Context, worldID uint32) (*types.Factions[uint32], error)
}
// Client implements SaerroClient.
type Client struct {
Config SaerroClientConfig
HttpClient *http.Client
}
type SaerroClientConfig struct {
BaseURL string
}
func NewSaerroClient(config ...SaerroClientConfig) SaerroClient {
c := SaerroClientConfig{
BaseURL: "https://saerro.ps2.live",
}
if len(config) != 0 {
c0 := config[0]
c.BaseURL = c0.BaseURL
}
return &Client{
Config: c,
HttpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
type graphqlQuery struct {
Query string `json:"query,omitempty"`
OperationName string `json:"operation_name,omitempty"`
Variables map[string]interface{} `json:"variables,omitempty"`
}
func (c *Client) Query(ctx context.Context, query string, out interface{}) error {
url, err := url.Parse(c.Config.BaseURL + "/graphql")
if err != nil {
return fmt.Errorf("error constructing query URL, %w", err)
}
q := graphqlQuery{
Query: query,
}
buf := bytes.Buffer{}
err = json.NewEncoder(&buf).Encode(q)
if err != nil {
return fmt.Errorf("saerro client: query: query encode failed, %w", err)
}
req := &http.Request{
URL: url,
Method: http.MethodPost,
Body: io.NopCloser(&buf),
Header: http.Header{},
}
req.Header.Add("content-type", "application/json")
res, err := c.HttpClient.Do(req)
if err != nil {
return fmt.Errorf("saerro client: query: http request failed, %w", err)
}
if res.StatusCode != 200 {
buf := bytes.Buffer{}
io.Copy(&buf, res.Body)
return fmt.Errorf("saerro client: query: status code not 200, got %d --\ntext: %s", res.StatusCode, buf.String())
}
err = json.NewDecoder(res.Body).Decode(out)
if err != nil {
return fmt.Errorf("saerro client: query: response decode failed, %w", err)
}
return nil
}
type SaerroResponse[T interface{}] struct {
Data T `json:"data"`
}
type SaerroWorld[T interface{}] struct {
World T `json:"world"`
}
type SaerroPopulation struct {
Population types.Factions[uint32] `json:"population"`
}
func (c *Client) GetWorldPopulation(ctx context.Context, worldID uint32) (*types.Factions[uint32], error) {
query := fmt.Sprintf(`{
world(by: { id: %d }) {
population {
nc
tr
vs
}
}
}`, worldID)
var data SaerroResponse[SaerroWorld[SaerroPopulation]]
err := c.Query(ctx, query, &data)
if err != nil {
return nil, err
}
return &data.Data.World.Population, nil
}

View file

@ -0,0 +1,27 @@
package mock
import (
"context"
saerrops2live "git.sapphic.engineer/ps2.live/ps2.live/clients/saerro.ps2.live"
"github.com/stretchr/testify/mock"
)
type MockSaerroClient struct {
mock.Mock
}
func (m *MockSaerroClient) Query(ctx context.Context, query string, out interface{}) error {
c := m.Called(ctx, query, out)
return c.Error(0)
}
func (m *MockSaerroClient) GetWorldPopulation(ctx context.Context, worldID uint32) (*saerrops2live.SaerroPopulation, error) {
c := m.Called(ctx, worldID)
rv := c.Get(0)
if rv == nil {
return nil, c.Error(1)
}
return rv.(*saerrops2live.SaerroPopulation), c.Error(1)
}

View file

@ -0,0 +1,11 @@
package fetcher
import "fmt"
var (
ErrNotImplemented = fmt.Errorf("fetcher: not implemented")
ErrNotSupported = fmt.Errorf("fetcher: not supported")
ErrFailedRequest = fmt.Errorf("fetcher: request failed")
ErrUnexpectedResponse = fmt.Errorf("fetcher: unexpected response structure")
ErrLikelyFailed = fmt.Errorf("fetcher: likely failed service")
)

View file

@ -0,0 +1,81 @@
package fetcher
import (
"context"
"errors"
"log"
"net/http"
"time"
saerrops2live "git.sapphic.engineer/ps2.live/ps2.live/clients/saerro.ps2.live"
"git.sapphic.engineer/ps2.live/ps2.live/shared/cache"
"git.sapphic.engineer/ps2.live/ps2.live/types"
)
type Fetcher struct {
Client *http.Client
Saerro saerrops2live.SaerroClient
Cache cache.ICache[uint32, types.AggPopServiceMap]
}
func NewFetcher() Fetcher {
return Fetcher{
Client: &http.Client{
Timeout: time.Second * 30,
},
Saerro: saerrops2live.NewSaerroClient(),
Cache: cache.NewCache[uint32, types.AggPopServiceMap](time.Minute * 3),
}
}
func (f *Fetcher) FetchByWorld(ctx context.Context, worldID uint32) (types.AggPopServiceMap, error) {
cached, ok := f.Cache.Get(worldID)
if ok {
return cached, nil
}
res := types.AggPopServiceMap{}
var err error
x := func(e error) {
if e != nil {
if errors.Is(e, ErrNotSupported) {
return
}
log.Println("FetchAll: errored, ", err)
}
}
res.Fisu, err = f.FetchFisu(ctx, worldID)
x(err)
res.Honu, err = f.FetchHonu(ctx, worldID)
x(err)
res.Saerro, err = f.FetchSaerro(ctx, worldID)
x(err)
res.Sanctuary, err = f.FetchSanctuary(ctx, worldID)
x(err)
res.Voidwell, err = f.FetchVoidwell(ctx, worldID)
x(err)
f.Cache.Put(worldID, res)
return res, nil
}
func (f *Fetcher) FetchAllWorlds(ctx context.Context, worldIDs []uint32) (map[uint32]types.AggPopServiceMap, error) {
out := map[uint32]types.AggPopServiceMap{}
var err error
for _, worldID := range worldIDs {
out[worldID], err = f.FetchByWorld(ctx, worldID)
if err != nil {
continue
}
}
return out, nil
}

View file

@ -0,0 +1,67 @@
package fetcher
import (
"context"
"encoding/json"
"fmt"
"git.sapphic.engineer/ps2.live/ps2.live/types"
)
func (f *Fetcher) FetchFisu(ctx context.Context, worldID uint32) (*types.Factions[uint32], error) {
url := f.makeFisuURL(worldID)
res, err := f.Client.Get(url)
if err != nil {
return nil, fmt.Errorf("FetchFisu: get to %s failed, %w", url, err)
}
if res.StatusCode != 200 {
return nil, ErrFailedRequest
}
var out fisuRoot
err = json.NewDecoder(res.Body).Decode(&out)
if err != nil {
return nil, fmt.Errorf("FetchFisu: failed to decode json, %w", err)
}
if len(out.Result) == 0 {
return nil, ErrUnexpectedResponse
}
fa := out.Result[0].ToFactions()
return &fa, nil
}
func (f *Fetcher) makeFisuURL(worldID uint32) string {
subdomain := "ps2"
switch worldID {
case 1000:
subdomain = "ps4us.ps2"
case 2000:
subdomain = "ps4eu.ps2"
}
return fmt.Sprintf("https://%s.fisu.pw/api/population/?world=%d", subdomain, worldID)
}
type fisuRoot struct {
Result []fisuFactions `json:"result"`
}
type fisuFactions struct {
VS uint32 `json:"vs"`
TR uint32 `json:"tr"`
NC uint32 `json:"nc"`
NS uint32 `json:"ns"`
}
func (fr fisuFactions) ToFactions() types.Factions[uint32] {
return types.Factions[uint32]{
NC: fr.NC,
TR: fr.TR,
VS: fr.VS,
Total: fr.NC + fr.TR + fr.VS + fr.NS,
}
}

View file

@ -0,0 +1,51 @@
package fetcher
import (
"context"
"encoding/json"
"fmt"
"git.sapphic.engineer/ps2.live/ps2.live/types"
)
func (f *Fetcher) FetchHonu(ctx context.Context, worldID uint32) (*types.Factions[uint32], error) {
url := fmt.Sprintf("https://wt.honu.pw/api/population/%d", worldID)
res, err := f.Client.Get(url)
if err != nil {
return nil, fmt.Errorf("FetchHonu: get to %s failed, %w", url, err)
}
if res.StatusCode != 200 {
return nil, ErrFailedRequest
}
var out honuResponse
err = json.NewDecoder(res.Body).Decode(&out)
if err != nil {
return nil, fmt.Errorf("FetchHonu: failed to decode json, %w", err)
}
fr := out.ToFactions()
return &fr, nil
}
type honuResponse struct {
NC uint32 `json:"nc"`
TR uint32 `json:"tr"`
VS uint32 `json:"vs"`
NS_NC uint32 `json:"ns_nc"`
NS_TR uint32 `json:"ns_tr"`
NS_VS uint32 `json:"ns_vs"`
}
func (hr honuResponse) ToFactions() types.Factions[uint32] {
return types.Factions[uint32]{
NC: hr.NC + hr.NS_NC,
TR: hr.TR + hr.NS_TR,
VS: hr.VS + hr.NS_VS,
Total: hr.NC + hr.NS_NC +
hr.TR + hr.NS_TR +
hr.VS + hr.NS_VS,
}
}

View file

@ -0,0 +1,28 @@
package fetcher
import (
"context"
"fmt"
"git.sapphic.engineer/ps2.live/ps2.live/types"
)
func (f *Fetcher) FetchSaerro(ctx context.Context, worldID uint32) (*types.Factions[uint32], error) {
sr, err := f.Saerro.GetWorldPopulation(ctx, worldID)
if err != nil {
return nil, fmt.Errorf("FetchSaerro: error querying, %w", err)
}
r := &types.Factions[uint32]{
NC: sr.NC,
TR: sr.TR,
VS: sr.NC,
Total: sr.NC + sr.TR + sr.VS,
}
if worldID != 19 && r.Total == 0 {
return nil, ErrLikelyFailed
}
return r, nil
}

View file

@ -0,0 +1,71 @@
package fetcher
import (
"context"
"encoding/json"
"fmt"
"time"
"git.sapphic.engineer/ps2.live/ps2.live/types"
)
func (f *Fetcher) FetchSanctuary(ctx context.Context, worldID uint32) (*types.Factions[uint32], error) {
// PS4 and Jaeger aren't supported on Sanctuary
if worldID == 1000 || worldID == 2000 || worldID == 19 {
return nil, ErrNotSupported
}
url := fmt.Sprintf("https://census.lithafalcon.cc/get/ps2/world_population?c:censusJSON=false&world_id=%d", worldID)
res, err := f.Client.Get(url)
if err != nil {
return nil, fmt.Errorf("FetchSanctuary: get to %s failed, %w", url, err)
}
if res.StatusCode != 200 {
return nil, ErrFailedRequest
}
var out sanctuaryRoot
err = json.NewDecoder(res.Body).Decode(&out)
if err != nil {
return nil, fmt.Errorf("FetchSanctuary: failed to decode json, %w", err)
}
if len(out.WorldPopulationList) == 0 {
return nil, ErrUnexpectedResponse
}
timestamp := time.Unix(int64(out.WorldPopulationList[0].Timestamp), 0)
if timestamp.Before(time.Now().Add(-time.Minute * 15)) {
return nil, ErrUnexpectedResponse
}
fr := out.WorldPopulationList[0].Population.ToFactions()
return &fr, nil
}
type sanctuaryRoot struct {
WorldPopulationList []sanctuaryWorld `json:"world_population_list"`
}
type sanctuaryWorld struct {
Population sanctuaryPopulation `json:"population"`
Timestamp uint64 `json:"timestamp"`
}
type sanctuaryPopulation struct {
VS uint32 `json:"VS"`
TR uint32 `json:"TR"`
NC uint32 `json:"NC"`
NSO uint32 `json:"NSO"`
}
func (sp sanctuaryPopulation) ToFactions() types.Factions[uint32] {
return types.Factions[uint32]{
NC: sp.NC,
TR: sp.TR,
VS: sp.VS,
Total: sp.NC + sp.TR + sp.VS + sp.NSO,
}
}

View file

@ -0,0 +1,77 @@
package fetcher
import (
"context"
"encoding/json"
"fmt"
"git.sapphic.engineer/ps2.live/ps2.live/types"
)
func (f *Fetcher) FetchVoidwell(ctx context.Context, worldID uint32) (*types.Factions[uint32], error) {
// PS4 isn't supported on Voidwell
if worldID >= 1000 {
return nil, ErrNotSupported
}
url := fmt.Sprintf("https://api.voidwell.com/ps2/worldstate/%d?platform=pc", worldID)
res, err := f.Client.Get(url)
if err != nil {
return nil, fmt.Errorf("FetchVoidwell: get to %s failed, %w", url, err)
}
if res.StatusCode != 200 {
return nil, ErrFailedRequest
}
var out voidwellRoot
err = json.NewDecoder(res.Body).Decode(&out)
if err != nil {
return nil, fmt.Errorf("FetchVoidwell: failed to decode json, %w", err)
}
vp := out.FlattenZonePopulations()
fr := vp.ToFactions()
fr.Total = out.OnlineCharacters
return &fr, nil
}
type voidwellRoot struct {
OnlineCharacters uint32 `json:"onlineCharacters"`
ZoneStates []voidwellZone `json:"zoneStates"`
}
func (vr voidwellRoot) FlattenZonePopulations() voidwellPopulation {
pop := voidwellPopulation{}
for _, zone := range vr.ZoneStates {
pop.NC += zone.Population.NC
pop.TR += zone.Population.TR
pop.VS += zone.Population.VS
pop.NS += zone.Population.NS
}
return pop
}
type voidwellZone struct {
Population voidwellPopulation `json:"population"`
}
type voidwellPopulation struct {
VS uint32 `json:"vs"`
TR uint32 `json:"tr"`
NC uint32 `json:"nc"`
NS uint32 `json:"ns"`
}
func (vp *voidwellPopulation) ToFactions() types.Factions[uint32] {
nsAvg := vp.NS / 3
return types.Factions[uint32]{
NC: vp.NC + nsAvg,
TR: vp.TR + nsAvg,
VS: vp.VS + nsAvg,
Total: vp.NC + vp.TR + vp.VS + vp.NS,
}
}

View file

@ -0,0 +1,54 @@
<!DOCTYPE html>
<meta charset="utf-8" />
<title>Aggregate PlanetSide 2 Population API</title>
<meta name="description" content="Multi-source population API" />
<style>
body {
font-family: sans-serif;
margin: 0;
padding: 0;
background-color: black;
color: white;
}
main {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
flex-direction: column;
font-weight: lighter;
text-align: center;
}
.big-header {
font-size: 2rem;
}
.api-list {
margin-top: 2rem;
}
.api-item {
display: block;
background-color: #334;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
margin: 0.5rem;
text-decoration: none;
color: white;
font-weight: bold;
}
</style>
<main>
<div>
<b>tl;dr:</b><br />
<span class="big-header"
>( fisu + honu + saerro + sanctuary + voidwell ) / 5</span
>
</div>
<div class="api-list">
<a class="api-item" href="/population/1">GET /population/{worldID} ▶️</a>
<a class="api-item" href="/population/all">GET /population/all ▶️</a>
</div>
<p>Results are cached for 3 minutes. Requesting faster is a dumb idea.</p>
</main>

162
cmd/agg.ps2.live/main.go Normal file
View file

@ -0,0 +1,162 @@
package main
import (
"context"
_ "embed"
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"git.sapphic.engineer/ps2.live/ps2.live/cmd/agg.ps2.live/fetcher"
"git.sapphic.engineer/ps2.live/ps2.live/types"
)
//go:embed html/index.html
var indexHtml []byte
var (
AllWorlds []uint32
Fetcher fetcher.Fetcher = fetcher.NewFetcher()
)
func main() {
addr := os.Getenv("ADDR")
if addr == "" {
addr = ":8001"
}
allWorldsEnv := os.Getenv("ALL_WORLDS")
if allWorldsEnv == "" {
allWorldsEnv = "1,10,17,19,40,1000,2000"
}
AllWorlds = envListToU32(allWorldsEnv)
mux := http.NewServeMux()
mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
rw.Header().Add("content-type", "text/html")
rw.Write(indexHtml)
})
mux.HandleFunc("/population/{id}", handlePop)
go func() {
for {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*60)
Fetcher.FetchAllWorlds(ctx, AllWorlds)
time.Sleep(time.Minute * 3)
cancel()
}
}()
err := http.ListenAndServe(addr, mux)
if err != nil {
log.Fatalln(err)
}
}
func envListToU32(input string) []uint32 {
ls := strings.Split(input, ",")
o := make([]uint32, len(ls))
for i, s := range ls {
v, err := strconv.Atoi(s)
if err != nil {
log.Println("error parsing ALL_WORLDS number, got: ", s)
continue
}
o[i] = uint32(v)
}
return o
}
func handlePop(rw http.ResponseWriter, req *http.Request) {
rw.Header().Add("content-type", "application/json")
worldIDStr := req.PathValue("id")
if worldIDStr == "all" {
handlePopAll(rw, req)
return
}
worldID, err := strconv.Atoi(worldIDStr)
if err != nil {
rw.WriteHeader(http.StatusBadRequest)
rw.Write([]byte(`{ "err":"bad request", "msg": "world ID did not parse as a number" }`))
log.Println("handlePop: worldID failed to parse, got: ", worldIDStr)
return
}
handlePopSingle(rw, req, uint32(worldID))
}
func handlePopSingle(rw http.ResponseWriter, req *http.Request, worldID uint32) {
ctx, cancel := context.WithTimeout(req.Context(), time.Second*30)
defer cancel()
services, err := Fetcher.FetchByWorld(ctx, worldID)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
rw.Write([]byte(`{ "err":"bad request", "msg": "fetching world failed" }`))
log.Println("handlePopOne: FetchByWorld failed, got: ", err)
return
}
factions := services.ToFactions()
world := types.AggPopWorld{
ID: worldID,
Services: services,
Factions: factions,
Average: factions.Total,
}
err = json.NewEncoder(rw).Encode(world)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
rw.Write([]byte(`{ "err":"bad request", "msg": "rendering world failed" }`))
log.Println("handlePopOne: json encode failed, got: ", err)
}
}
func handlePopAll(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), time.Second*30)
defer cancel()
worldMap, err := Fetcher.FetchAllWorlds(ctx, AllWorlds)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
rw.Write([]byte(`{ "err":"bad request", "msg": "fetching all worlds failed" }`))
log.Println("handlePopAll: FetchAllWorlds failed, got: ", err)
return
}
out := []types.AggPopWorld{}
for worldID, services := range worldMap {
factions := services.ToFactions()
world := types.AggPopWorld{
ID: worldID,
Services: services,
Factions: factions,
Average: factions.Total,
}
out = append(out, world)
}
err = json.NewEncoder(rw).Encode(out)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
rw.Write([]byte(`{ "err":"bad request", "msg": "rendering all worlds failed" }`))
log.Println("handlePopAll: json encode failed, got: ", err)
}
}

View file

@ -0,0 +1,5 @@
package main
func main() {
}

View file

@ -0,0 +1,14 @@
main {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
.center {
justify-content: center;
}
.v-center {
align-items: center;
}
}

View file

5
cmd/ps2.live/main.go Normal file
View file

@ -0,0 +1,5 @@
package main
func main() {
}

View file

@ -0,0 +1,6 @@
package main
import "embed"
//go:embed assets templates
var StaticFileFS embed.FS

View file

@ -0,0 +1,4 @@
<main class="center v-center">
<h1>404 Not Found</h1>
<p>{{ or .PageData.RandomText "why are you here" }}</p>
</main>

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<head>
<meta charset="utf-8" />
<title>
{{ or .Meta.Title "ps2.live" }}
</title>
<link rel="stylesheet" href="/assets/global.css" />
<script src="/assets/global.js" async defer></script>
</head>
<body>
{{ with $page := or .TemplateName "404" }}
{{ template $page . }}
{{ end }}
</body>

View file

@ -0,0 +1 @@
<h1>hello world!!!</h1>

58
flake.lock generated Normal file
View file

@ -0,0 +1,58 @@
{
"nodes": {
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1717285511,
"narHash": "sha256-iKzJcpdXih14qYVcZ9QC9XuZYnPc6T8YImb6dX166kw=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "2a55567fcf15b1b1c7ed712a2c6fadaec7412ea8",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1717786204,
"narHash": "sha256-4q0s6m0GUcN7q+Y2DqD27iLvbcd1G50T2lv08kKxkSI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "051f920625ab5aabe37c920346e3e69d7d34400e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1717284937,
"narHash": "sha256-lIbdfCsf8LMFloheeE6N31+BMIeixqyQWbSr2vk79EQ=",
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

15
flake.nix Normal file
View file

@ -0,0 +1,15 @@
{
description = "ps2.live";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
};
outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" ];
perSystem = { config, self', pkgs, lib, system, ... }: {
devShells.default = import ./shell.nix { inherit pkgs; };
};
};
}

12
go.mod Normal file
View file

@ -0,0 +1,12 @@
module git.sapphic.engineer/ps2.live/ps2.live
go 1.22.3
require github.com/stretchr/testify v1.9.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

11
go.sum Normal file
View file

@ -0,0 +1,11 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

45
shared/cache/cache.go vendored Normal file
View file

@ -0,0 +1,45 @@
package cache
import "time"
type Object[V interface{}] struct {
Value V
ExpiresAt time.Time
}
type Cache[K comparable, V interface{}] struct {
data map[K]Object[V]
timeout time.Duration
}
type ICache[K comparable, V interface{}] interface {
Get(K) (V, bool)
Put(K, V)
}
func NewCache[K comparable, V interface{}](timeout time.Duration) Cache[K, V] {
return Cache[K, V]{
data: map[K]Object[V]{},
timeout: timeout,
}
}
func (c Cache[K, V]) Get(key K) (out V, ok bool) {
o, ok := c.data[key]
if !ok {
return out, false
}
if o.ExpiresAt.Before(time.Now()) {
return out, false
}
return o.Value, true
}
func (c Cache[K, V]) Put(key K, value V) {
c.data[key] = Object[V]{
Value: value,
ExpiresAt: time.Now().Add(c.timeout),
}
}

40
shared/cache/cache_test.go vendored Normal file
View file

@ -0,0 +1,40 @@
package cache_test
import (
"testing"
"time"
"git.sapphic.engineer/ps2.live/ps2.live/shared/cache"
"github.com/stretchr/testify/assert"
)
func TestCacheHit(t *testing.T) {
c := cache.NewCache[uint32, string](time.Second * 1)
c.Put(1, "1")
r, ok := c.Get(1)
assert.True(t, ok)
assert.Equal(t, "1", r)
}
func TestCacheMissTimeout(t *testing.T) {
c := cache.NewCache[uint32, string](time.Second * 1)
c.Put(1, "1")
time.Sleep(time.Second*1 + time.Millisecond)
r, ok := c.Get(1)
assert.False(t, ok)
assert.Empty(t, r)
}
func TestCacheMissNonexisting(t *testing.T) {
c := cache.NewCache[uint32, string](time.Second * 1)
r, ok := c.Get(1)
assert.False(t, ok)
assert.Empty(t, r)
}

6
shell.nix Normal file
View file

@ -0,0 +1,6 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
go
];
}

70
types/aggpop.go Normal file
View file

@ -0,0 +1,70 @@
package types
type AggPopWorld struct {
ID uint32 `json:"id"`
Average uint32 `json:"average"`
Factions Factions[uint32] `json:"factions"`
Services AggPopServiceMap `json:"services"`
}
type AggPopServiceMap struct {
Fisu *Factions[uint32] `json:"fisu,omitempty"`
Honu *Factions[uint32] `json:"honu,omitempty"`
Saerro *Factions[uint32] `json:"saerro,omitempty"`
Sanctuary *Factions[uint32] `json:"sanctuary,omitempty"`
Voidwell *Factions[uint32] `json:"voidwell,omitempty"`
}
// Average the 5 services by faction
func (sm AggPopServiceMap) ToFactions() Factions[uint32] {
var servicesCount, tTR, tNC, tVS, tTotal uint32
if sm.Fisu != nil {
tTR += sm.Fisu.TR
tNC += sm.Fisu.NC
tVS += sm.Fisu.VS
tTotal += sm.Fisu.Total
servicesCount++
}
if sm.Honu != nil {
tTR += sm.Honu.TR
tNC += sm.Honu.NC
tVS += sm.Honu.VS
tTotal += sm.Honu.Total
servicesCount++
}
if sm.Saerro != nil {
tTR += sm.Saerro.TR
tNC += sm.Saerro.NC
tVS += sm.Saerro.VS
tTotal += sm.Saerro.Total
servicesCount++
}
if sm.Sanctuary != nil {
tTR += sm.Sanctuary.TR
tNC += sm.Sanctuary.NC
tVS += sm.Sanctuary.VS
tTotal += sm.Sanctuary.Total
servicesCount++
}
if sm.Voidwell != nil {
tTR += sm.Voidwell.TR
tNC += sm.Voidwell.NC
tVS += sm.Voidwell.VS
tTotal += sm.Voidwell.Total
servicesCount++
}
f := Factions[uint32]{
NC: tNC / servicesCount,
TR: tTR / servicesCount,
VS: tVS / servicesCount,
Total: tTotal / servicesCount,
}
return f
}

34
types/metagame.go Normal file
View file

@ -0,0 +1,34 @@
package types
import "time"
type MetagameWorld struct {
ID uint32 `json:"id"`
Zones []MetagameZone `json:"zones"`
CachedAt time.Time `json:"cached_at"`
}
type MetagameZone struct {
ID uint32 `json:"id"`
Locked bool `json:"locked"`
Alert *MetagameAlert `json:"alert,omitempty"`
Territory Factions[float32] `json:"territory"`
LockedSince time.Time `json:"locked_since,omitempty"`
}
type MetagameAlert struct {
ID uint32 `json:"id"`
Zone uint32 `json:"zone"`
EndTime time.Time `json:"end_time,omitempty"`
StartTime time.Time `json:"start_time,omitempty"`
AlertType string `json:"alert_type"`
PS2Alerts string `json:"ps2alerts"`
Percentages Factions[float32] `json:"percentages"`
}
type Factions[T float32 | uint32] struct {
TR T `json:"tr"`
NC T `json:"nc"`
VS T `json:"vs"`
Total T `json:"total"`
}