├── .dockerignore ├── .gitignore ├── .luacheckrc ├── .luarc.json ├── .stylua.toml ├── Makefile ├── NETWORK.md ├── README.md ├── auth-proxy ├── .dockerignore ├── Dockerfile ├── cmd │ └── main.go └── pkg │ ├── config │ └── config.go │ ├── data │ └── data.go │ ├── integration │ ├── int_utils │ │ └── utils.go │ └── ws_tests │ │ └── ws.go │ ├── protocol │ └── protocol.go │ ├── proxy │ └── proxy.go │ ├── server │ └── server.go │ └── ws │ └── ws.go ├── go.mod ├── go.sum ├── lua └── vim-guys │ ├── init.lua │ ├── reload.lua │ ├── socket │ ├── frame.lua │ ├── frame_spec.lua │ └── init.lua │ ├── test_utils.lua │ └── window │ ├── float.lua │ ├── init.lua │ └── win.lua ├── scripts └── tests │ └── minimal.vim ├── sst.config.ts ├── test └── vim-guys ├── .dockerignore └── Dockerfile /.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | # sst 3 | .sst -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # sst 3 | .sst 4 | .env 5 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | std = luajit 2 | cache = true 3 | codes = true 4 | ignore = { 5 | "111", -- Setting an undefined global variable. (for ok, _ = pcall...) 6 | "211", -- Unused local variable. 7 | "411", -- Redefining a local variable. 8 | } 9 | read_globals = { "vim", "describe", "it", "bit", "assert", "before_each", "after_each" } 10 | 11 | 12 | -------------------------------------------------------------------------------- /.luarc.json: -------------------------------------------------------------------------------- 1 | { 2 | "diagnostics.globals": [ 3 | "bit", 4 | "vim", 5 | "it", 6 | "describe", 7 | "before_each", 8 | "after_each", 9 | "continue" 10 | ] 11 | } -------------------------------------------------------------------------------- /.stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 80 2 | line_endings = "Unix" 3 | indent_type = "Spaces" 4 | indent_width = 4 5 | quote_style = "AutoPreferDouble" 6 | 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | lua_fmt: 2 | echo "===> Formatting" 3 | stylua lua/ --config-path=.stylua.toml 4 | 5 | lua_lint: 6 | echo "===> Linting" 7 | luacheck lua/ --globals vim 8 | 9 | lua_test: 10 | echo "===> Testing" 11 | nvim --headless --noplugin -u scripts/tests/minimal.vim \ 12 | -c "PlenaryBustedDirectory lua/vim-guys {minimal_init = 'scripts/tests/minimal.vim'}" 13 | 14 | lua_clean: 15 | echo "===> Cleaning" 16 | rm /tmp/lua_* 17 | 18 | pr-ready: lua_test lua_clean lua_fmt lua_lint 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /NETWORK.md: -------------------------------------------------------------------------------- 1 | # High Level View 2 | We are not going to create a game spawning service yet. Unless we get some how 3 | so popular that i cannot run ~10,000s of users on a single cpu, then... well, 4 | then i'll have to scale horizontally 5 | 6 | ## Why JSON? 7 | I just didn't want to write parser 3 times. so i am just using zig as the data 8 | packet but the header is binary. This will allow for fast intervention for 9 | auth-proxy but simplicity. Especially since i have no idea if i have the right 10 | packets! 11 | 12 | ## topology 13 | client([ ws conn ]) -> vimguys.theprimeagen.com 14 | * this is for authentication 15 | * reverse proxy for game to prevent game being overloaded 16 | 17 | vimguys([ tcp conn]) -> internal.vimguys.game 18 | * the ackshual game 19 | * the game runner is going to have to create new games and resolve games 20 | 21 | ## Network Protocol 22 | High level look at the protocol is the following: 23 | [ version(2) | type(2) | len(2) | player_id(4) | game_id(4) | data(len) ] 24 | 25 | ### version 26 | This represents two different types of versioning. 27 | 28 | * binary protocol version. This should rarely, if ever change. 29 | * new `types` This will be incremented and will be considered breaking every time. 30 | 31 | **Update required** if out of sync 32 | Should never change mid game 33 | 34 | ### player_id & game_id 35 | this will be used by the game and the proxy. They have no affect on the 36 | clientside 37 | 38 | ### Authentication 39 | ```typescript 40 | type Auth = { 41 | data: UUID 42 | } 43 | 44 | type AuthResponse = { 45 | data: boolean 46 | } 47 | ``` 48 | 49 | ### Connection Status / Maintenance 50 | ```typescript 51 | type Ping = { 52 | data: null 53 | } 54 | 55 | type Pong = { 56 | data: null 57 | } 58 | 59 | type ConnectionTimings = { 60 | data: { 61 | sent: number, 62 | received?: number 63 | } 64 | } 65 | 66 | type ConnError = { 67 | data: string 68 | } 69 | 70 | type MessageDisplay = { 71 | msg: string, 72 | foreground: Hex 73 | background?: Hex 74 | bold?: boolean // i think vim supports this 75 | } 76 | 77 | type BillboardMessage = { 78 | title: MessageDisplay, 79 | display: MessageDisplay, 80 | time: number 81 | } 82 | 83 | type GameStatusMessage = 84 | // displays a large central box 85 | BillboardMessage & { 86 | type: 1, 87 | } 88 | 89 | // displays a game message in bottom row 90 | | { 91 | type: 2, 92 | display: MessageDisplay, 93 | time: number 94 | } 95 | 96 | type GameStatus = { 97 | data: GameStatusMessage 98 | } 99 | 100 | type SystemMessage = { 101 | data: string 102 | } 103 | 104 | ``` 105 | 106 | ### Rendering 107 | **Needs** 108 | Some assets, players, will move in non predictable patterns 109 | Some assets, items, could visible until someone picks one up 110 | Some assets, walls, never move 111 | Some assets, moving walls, could move with a cycle 112 | 113 | ```typescript 114 | type RenderedObject = 115 | // this should cover 1 - 3 116 | { 117 | type: 1, 118 | id: number, 119 | pos: Vec2 120 | rect: [Vec2, Vec2] // n x m 121 | color: 122 | number | // all one color 123 | number[] // length = n x m 124 | } 125 | 126 | // this should cover number 2 127 | | { 128 | type: 2, 129 | startingPos: [Vec2, Vec2] // n x m 130 | endingPos: [Vec2, Vec2] // n x m 131 | timings: { 132 | cycleTime: number, 133 | offsetTime: number, 134 | cycleDelay: number, 135 | } 136 | rect: [Vec2, Vec2] // n x m 137 | color: 138 | number | // all one color 139 | number[] // length = n x m 140 | } 141 | 142 | // navigation markers 143 | | { 144 | type: 3 145 | pos: Vec2 146 | keys: string 147 | } 148 | 149 | type Rendered = { 150 | data: RenderedObject 151 | } 152 | ``` 153 | 154 | ### Input 155 | **Context** 156 | Now with vim we do not get to know if two keys are held down at once. But we 157 | have vim navigation which has been designed around the fact you cannot do that. 158 | We can also not know if two keys are held down at the same time. So this does 159 | limit us in the natural gaming sense. 160 | 161 | ```typescript 162 | type Input = { 163 | data: { key: string } 164 | } 165 | ``` 166 | 167 | ### Game Information 168 | ```typescript 169 | type PlayerId = { 170 | data: number 171 | } 172 | type GameCountdown = { 173 | data: number 174 | } 175 | 176 | type GameOver = { 177 | data: { 178 | win: boolean // i am sure i will want more information here 179 | display: BillboardMessage 180 | stats: Stats 181 | } 182 | } 183 | 184 | type MiniGameEnd = { 185 | win: boolean 186 | display: BillboardMessage 187 | } 188 | 189 | type MiniGameInit = { 190 | display: BillboardMessage 191 | map: { 192 | width: number, 193 | height: number, 194 | renders: RenderedObject[] 195 | } 196 | } 197 | 198 | type GameMessage = { 199 | data: MiniGameInit | MiniGameEnd 200 | } 201 | 202 | type Stats = { ... unknown at the time of writing ... } 203 | ``` 204 | 205 | ## Decoding strategy 206 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What is this? 2 | This is the greatest game(s) for NeoVim. The idea is that not only will it 3 | make learning motions fun, it will also be competitive and filled with stats!! 4 | 5 | # Installation 6 | TODO: there is no installation yet, we are not done 7 | 8 | # More Info 9 | * [Network](NETWORK.md) 10 | -------------------------------------------------------------------------------- /auth-proxy/.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | # sst 3 | .sst -------------------------------------------------------------------------------- /auth-proxy/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use official Golang image to build the app 2 | FROM golang:1.24-alpine as builder 3 | 4 | WORKDIR /app 5 | COPY ./go.mod . 6 | COPY ./go.sum . 7 | COPY . . 8 | 9 | # Build the Go app 10 | RUN go mod download 11 | RUN go build -o main ./cmd/main.go 12 | 13 | # Create a minimal image for deployment 14 | FROM alpine:latest 15 | WORKDIR /root/ 16 | COPY --from=builder /app/main . 17 | EXPOSE 42000 18 | 19 | CMD ["./main"] 20 | 21 | -------------------------------------------------------------------------------- /auth-proxy/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/joho/godotenv" 5 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/config" 6 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/proxy" 7 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/server" 8 | ) 9 | 10 | func main() { 11 | godotenv.Load() 12 | 13 | ctx := config.NewAuthConfig(config.ProxyConfigParamsFromEnv()) 14 | p := proxy.NewProxy(ctx) 15 | s := server.NewProxyServer() 16 | 17 | p.AddInterceptor(s) 18 | p.Start() 19 | } 20 | -------------------------------------------------------------------------------- /auth-proxy/pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/jmoiron/sqlx" 13 | ) 14 | 15 | const PROXY_SERVER_ID = 1 16 | 17 | type ProxyConfigParams struct { 18 | DBUrl string 19 | DBToken string 20 | 21 | Port uint16 22 | AuthenticationTimeout time.Duration 23 | ReadLimit int 24 | } 25 | 26 | type ProxyConfigJSON struct { 27 | Port uint16 `json:"port"` 28 | AuthenticationTimeoutMS int `json:"authentication_timeout_ms"` 29 | WSReadLimit int `json:"ws_read_limit"` 30 | } 31 | 32 | func defaultProxyParams() *ProxyConfigParams { 33 | return &ProxyConfigParams{ 34 | DBUrl: "", 35 | DBToken: "", 36 | 37 | Port: 42000, 38 | AuthenticationTimeout: time.Second * 3, 39 | ReadLimit: 50, 40 | } 41 | } 42 | 43 | func configureFromConfigFile(config *ProxyConfigParams) { 44 | args := os.Args 45 | if len(args) < 2 { 46 | return 47 | } 48 | contents, err := os.Open(args[1]) 49 | if err != nil { 50 | return 51 | } 52 | 53 | var params ProxyConfigJSON 54 | err = json.NewDecoder(contents).Decode(¶ms) 55 | if err != nil { 56 | return 57 | } 58 | 59 | if params.Port > 0 { 60 | config.Port = uint16(params.Port) 61 | } 62 | 63 | if params.AuthenticationTimeoutMS > 0 { 64 | config.AuthenticationTimeout = time.Millisecond * time.Duration(params.AuthenticationTimeoutMS) 65 | } 66 | 67 | if params.WSReadLimit > 0 { 68 | config.ReadLimit = params.WSReadLimit 69 | } 70 | } 71 | 72 | func getEnvNumber(prop string) int { 73 | value := os.Getenv(prop) 74 | if value == "" { 75 | return -1 76 | } 77 | 78 | v, err := strconv.Atoi(value) 79 | if err != nil { 80 | return -1 81 | } 82 | return v 83 | } 84 | 85 | func configureFromCLIParams(config *ProxyConfigParams) { 86 | port := uint(0) 87 | authenticationTimeout := int64(0) 88 | readLimit := 0 89 | 90 | flag.UintVar(&port, "port", 0, "the port to launch the proxy server on") 91 | flag.Int64Var(&authenticationTimeout, "auth-timeout", -1, "time to wait before kicking the websocket, specify in MS") 92 | flag.IntVar(&readLimit, "read-limit", -1, "the maximum amount of bytes receviable by client ws. default=50") 93 | flag.Parse() 94 | 95 | if port != 0 { 96 | config.Port = uint16(port) 97 | } 98 | 99 | if authenticationTimeout != -1 { 100 | config.AuthenticationTimeout = time.Millisecond * time.Duration(authenticationTimeout) 101 | } 102 | 103 | if readLimit != -1 { 104 | config.ReadLimit = readLimit 105 | } 106 | } 107 | 108 | func configureFromEnv(config *ProxyConfigParams) { 109 | port := getEnvNumber("PORT") 110 | authenticationTimeout := getEnvNumber("AUTHENTICATION_TIMEOUT_MS") 111 | readLimit := getEnvNumber("WS_READ_LIMIT") 112 | 113 | if port > 0 { 114 | config.Port = uint16(port) 115 | } 116 | 117 | if authenticationTimeout > 0 { 118 | config.AuthenticationTimeout = time.Millisecond * time.Duration(authenticationTimeout) 119 | } 120 | 121 | if readLimit > 0 { 122 | config.ReadLimit = readLimit 123 | } 124 | 125 | dbUrl := os.Getenv("TURSO_DATABASE_URL") 126 | dbToken := os.Getenv("TURSO_AUTH_TOKEN") 127 | if dbUrl != "" { 128 | config.DBUrl = dbUrl 129 | } 130 | if dbToken != "" { 131 | config.DBToken = dbToken 132 | } 133 | } 134 | 135 | func ProxyConfigParamsFromEnv() *ProxyConfigParams { 136 | config := defaultProxyParams() 137 | configureFromEnv(config) 138 | configureFromConfigFile(config) 139 | configureFromCLIParams(config) 140 | return config 141 | } 142 | 143 | type ProxyContextWS struct { 144 | ReadLimit int 145 | AuthenticationTimeout time.Duration 146 | } 147 | 148 | type ProxyContext struct { 149 | WS ProxyContextWS 150 | Port uint16 151 | DB *sqlx.DB 152 | Logger *slog.Logger 153 | } 154 | 155 | func (p *ProxyContext) HasDatabase() bool { 156 | return p.DB != nil 157 | } 158 | 159 | func (p *ProxyContext) addDB(config *ProxyConfigParams) *ProxyContext { 160 | connStr := fmt.Sprintf("libsql://%s?authToken=%s", config.DBUrl, config.DBToken) 161 | db, err := sqlx.Connect("libsql", connStr) 162 | if err != nil { 163 | p.Logger.Error("Failed to connect to Turso database", "error", err) 164 | return p 165 | } 166 | // Test the connection 167 | if err := db.Ping(); err != nil { 168 | p.Logger.Error("Failed to ping database", "error", err) 169 | db.Close() 170 | } 171 | 172 | p.Logger.Warn("Successfully connected to Turso database!") 173 | p.DB = db 174 | return p 175 | } 176 | 177 | func (p *ProxyContext) setWSConfig(config *ProxyConfigParams) *ProxyContext { 178 | p.WS = ProxyContextWS{ 179 | ReadLimit: config.ReadLimit, 180 | AuthenticationTimeout: config.AuthenticationTimeout, 181 | } 182 | return p 183 | } 184 | 185 | func (p *ProxyContext) setTopLevelInformation(config *ProxyConfigParams) *ProxyContext { 186 | p.Port = config.Port 187 | 188 | // probably need a way to configure the slogger 189 | p.Logger = slog.Default() 190 | 191 | return p 192 | } 193 | 194 | func (p *ProxyContext) Close() { 195 | if p.DB != nil { 196 | err := p.DB.Close() 197 | if err != nil { 198 | p.Logger.Error("closing db resulted in error", "error", err) 199 | } 200 | } 201 | } 202 | 203 | func NewAuthConfig(config *ProxyConfigParams) *ProxyContext { 204 | ctx := &ProxyContext{} 205 | 206 | return ctx. 207 | addDB(config). 208 | setWSConfig(config). 209 | setTopLevelInformation(config) 210 | } 211 | -------------------------------------------------------------------------------- /auth-proxy/pkg/data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "fmt" 5 | 6 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/config" 7 | ) 8 | 9 | // UserMapping represents the structure of the user_mapping table 10 | type UserMapping struct { 11 | UserID string `db:"userId"` 12 | UUID string `db:"uuid"` 13 | } 14 | 15 | func (u *UserMapping) String() string { 16 | return fmt.Sprintf("UserId: %s -- UUID: %s", u.UserID, u.UUID) 17 | } 18 | 19 | func AccountExists(ctx config.ProxyContext, uuid string) bool { 20 | query := "SELECT userId, uuid FROM user_mapping WHERE uuid = ?" 21 | var mapping UserMapping 22 | err := ctx.DB.Get(&mapping, query, uuid) 23 | if err != nil { 24 | ctx.Logger.Debug("unable to get user token", "error", err) 25 | } 26 | 27 | return err == nil 28 | } 29 | -------------------------------------------------------------------------------- /auth-proxy/pkg/integration/int_utils/utils.go: -------------------------------------------------------------------------------- 1 | package intutils 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | "time" 8 | 9 | "github.com/gorilla/websocket" 10 | "github.com/jmoiron/sqlx" 11 | "github.com/stretchr/testify/require" 12 | "github.com/tursodatabase/libsql-client-go/libsql" 13 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/config" 14 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/protocol" 15 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/proxy" 16 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/server" 17 | ) 18 | 19 | func CreateDB(t *testing.T, f string) *sqlx.DB { 20 | connStr := fmt.Sprintf("file://%s", f) 21 | db, err := sqlx.Connect("libsql", connStr) 22 | require.NoError(t, err, "unable to make connection to database", "error", err) 23 | 24 | return db 25 | } 26 | func ReadNextBinary(conn *websocket.Conn, dur time.Duration) (*protocol.ProtocolFrame, error) { 27 | out := make(chan []byte, 1) 28 | errOut := make(chan error, 1) 29 | go func() { 30 | data, err := conn.ReadBinary() 31 | if err == nil { 32 | out <- data 33 | } else { 34 | errOut <- err 35 | } 36 | }() 37 | 38 | select { 39 | case <-time.NewTimer(dur).C: 40 | return nil, fmt.Errorf("time limit exceeded") 41 | case err := <- errOut: 42 | return nil, err 43 | case d := <- out: 44 | return protocol.FromData(d, 0) 45 | } 46 | } 47 | 48 | func CreateWS(t *testing.T, port uint, token string) (protocol.ProtocolFrame, *websocket.Conn) { 49 | conn, err := websocket.Dial(fmt.Sprintf("ws://localhost:%d", port)) 50 | require.NoError(t, "unable to initialize websocket client connection", "error", err) 51 | 52 | auth := protocol.NewClientProtocolFrame(protocol.Authenticate, []byte(token)) 53 | err = conn.Write(auth.Frame()); 54 | require.NoError(t, "unable to send authentication packet", "error", err) 55 | 56 | p, err := ReadNextBinary(conn, time.Microsecond * 100) 57 | require.NoError(t, "error waiting for auth message back", "error", err) 58 | 59 | return p, conn 60 | } 61 | 62 | func LaunchServer(t *testing.T, c config.ProxyContext, maxAttempts int) (*proxy.Proxy, *server.ProxyServer) { 63 | p := proxy.NewProxy(c) 64 | s := server.NewProxyServer() 65 | p.AddInterceptor(s) 66 | 67 | for range maxAttempts { 68 | time.Sleep(time.Millisecond) 69 | _, err := http.Get(fmt.Sprintf("http://localhost:%d/health", c.Port)) 70 | if err == nil { 71 | return p, s 72 | } 73 | } 74 | 75 | require.Never(t, "unable to launch server after waiting for maxAttempts", "maxAttempts", maxAttempts) 76 | return nil, nil 77 | } 78 | -------------------------------------------------------------------------------- /auth-proxy/pkg/integration/ws_tests/ws.go: -------------------------------------------------------------------------------- 1 | package wstests 2 | 3 | import "testing" 4 | 5 | func TestWSAuth(t *testing.T) { 6 | } 7 | 8 | -------------------------------------------------------------------------------- /auth-proxy/pkg/protocol/protocol.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "log/slog" 7 | ) 8 | 9 | var VersionMismatch = fmt.Errorf("protocol version mismatch") 10 | var UnknownType = fmt.Errorf("unknown type") 11 | var MalformedFrame = fmt.Errorf("malformed frame") 12 | var LengthMismatch = fmt.Errorf("length mismatch") 13 | 14 | type ProtocolType int 15 | const VERSION = 1 16 | const HEADER_LENGTH = 14 17 | const ( 18 | Authenticate ProtocolType = 1 19 | Authenticated ProtocolType = 2 20 | ) 21 | 22 | func isType(t ProtocolType) bool { 23 | // TODO better than this?? 24 | return t >= Authenticate && t <= Authenticated 25 | } 26 | type ProtocolFrame struct { 27 | Type ProtocolType `json:"type"` 28 | Len int `json:"len"` 29 | Data []byte `json:"data"` 30 | PlayerId int `json:"playerId"` 31 | GameId int `json:"gameId"` // TODO: wtf do i do here... 32 | Original []byte 33 | } 34 | 35 | func Auth(auth bool, playerId int) *ProtocolFrame { 36 | b := byte(0) 37 | if auth { 38 | b = 1 39 | } 40 | return NewProtocolFrame(Authenticate, []byte{b}, playerId) 41 | } 42 | 43 | func NewClientProtocolFrame(t ProtocolType, data []byte) *ProtocolFrame { 44 | return &ProtocolFrame{ 45 | Type: t, 46 | Len: len(data), 47 | Data: data, 48 | PlayerId: 0, 49 | } 50 | } 51 | 52 | func NewProtocolFrame(t ProtocolType, data []byte, playerId int) *ProtocolFrame { 53 | return &ProtocolFrame{ 54 | Type: t, 55 | Len: len(data), 56 | Data: data, 57 | PlayerId: playerId, 58 | } 59 | } 60 | 61 | func FromData(data []byte, playerId int) (*ProtocolFrame, error) { 62 | if len(data) < HEADER_LENGTH { 63 | return nil, MalformedFrame 64 | } 65 | original := data[0:] 66 | 67 | version := binary.BigEndian.Uint16(data) 68 | if version != VERSION { 69 | return nil, VersionMismatch 70 | } 71 | 72 | data = data[2:] 73 | t := ProtocolType(binary.BigEndian.Uint16(data)) 74 | if !isType(t) { 75 | return nil, UnknownType 76 | } 77 | 78 | data = data[2:] 79 | length := int(binary.BigEndian.Uint16(data)) 80 | 81 | data = data[2:] // move forward by length 82 | 83 | data = data[8:] // erases playerid + gameid 84 | slog.Info("length parsed", "length", length, "data remaining", len(data)) 85 | 86 | if len(data) != length { 87 | return nil, LengthMismatch 88 | } 89 | 90 | return &ProtocolFrame{ 91 | Type: t, 92 | Len: length, 93 | Data: data, 94 | PlayerId: playerId, 95 | GameId: 0, 96 | Original: original, 97 | }, nil 98 | } 99 | 100 | func (f *ProtocolFrame) Frame() []byte { 101 | // TODO still probably bad idea... 102 | if f.Original != nil { 103 | return f.Original 104 | } 105 | 106 | length := HEADER_LENGTH + f.Len 107 | data := make([]byte, length, length) 108 | 109 | writer := data[:HEADER_LENGTH] 110 | binary.BigEndian.PutUint16(writer, VERSION) 111 | 112 | writer = writer[2:] 113 | binary.BigEndian.PutUint16(writer, uint16(f.Type)) 114 | 115 | // TODO write assert lib 116 | writer = writer[2:] 117 | binary.BigEndian.PutUint16(writer, uint16(f.Len)) 118 | 119 | writer = writer[2:] 120 | binary.BigEndian.PutUint32(writer, uint32(f.PlayerId)) 121 | 122 | copy(data[HEADER_LENGTH:], f.Data) 123 | return data 124 | } 125 | 126 | 127 | -------------------------------------------------------------------------------- /auth-proxy/pkg/proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "log/slog" 5 | "slices" 6 | "sync" 7 | "time" 8 | 9 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/config" 10 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/protocol" 11 | ) 12 | 13 | type Interceptor interface { 14 | Id() int 15 | Start(IProxy) error 16 | Name() string 17 | Close() error 18 | } 19 | 20 | type IProxy interface { 21 | AddInterceptor(i Interceptor) 22 | RemoveInterceptor(i Interceptor) 23 | PushToGame(*protocol.ProtocolFrame, Interceptor) error 24 | PushToClient(*protocol.ProtocolFrame) error 25 | Context() *config.ProxyContext 26 | } 27 | 28 | type Proxy struct { 29 | // TODO interceptors organized by game_id and by player_id 30 | // There is going to be specific messages and broadcast messages 31 | mutex sync.Mutex 32 | interceptors []Interceptor 33 | context *config.ProxyContext 34 | } 35 | 36 | func NewProxy(ctx *config.ProxyContext) *Proxy { 37 | return &Proxy{ 38 | mutex: sync.Mutex{}, 39 | interceptors: []Interceptor{}, 40 | context: ctx, 41 | } 42 | } 43 | 44 | func (r *Proxy) Context() *config.ProxyContext { 45 | return r.context 46 | } 47 | 48 | func (r *Proxy) PushToGame(frame *protocol.ProtocolFrame, i Interceptor) error { 49 | slog.Info("to game", "frame", frame, "from", i.Id()) 50 | return nil 51 | } 52 | 53 | func (r *Proxy) PushToClient(frame *protocol.ProtocolFrame) error { 54 | slog.Info("to client", "frame", frame) 55 | return nil 56 | } 57 | 58 | func (r *Proxy) RemoveInterceptor(i Interceptor) { 59 | r.mutex.Lock() 60 | defer r.mutex.Unlock() 61 | found := slices.DeleteFunc(r.interceptors, func(i Interceptor) bool { 62 | return i.Id() == i.Id() 63 | }) 64 | 65 | for _, v := range found { 66 | v.Close() 67 | } 68 | } 69 | 70 | func (r *Proxy) AddInterceptor(i Interceptor) { 71 | go func() { 72 | err := i.Start(r) 73 | if err != nil { 74 | slog.Error("Interceptor failed to start", "name", i.Name(), "id", i.Id()) 75 | r.RemoveInterceptor(i) 76 | } 77 | }() 78 | r.mutex.Lock() 79 | defer r.mutex.Unlock() 80 | r.interceptors = append(r.interceptors, i) 81 | } 82 | 83 | 84 | func (r *Proxy) Start() error { 85 | for { 86 | time.Sleep(time.Second) 87 | } 88 | // TODO I should be reading from messages from game and messages from clients 89 | return nil 90 | } 91 | 92 | -------------------------------------------------------------------------------- /auth-proxy/pkg/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | 9 | "github.com/gorilla/websocket" 10 | "github.com/labstack/echo/v4" 11 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/config" 12 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/proxy" 13 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/ws" 14 | ) 15 | 16 | // Upgrader configures the WebSocket connection 17 | var upgrader = websocket.Upgrader{ 18 | CheckOrigin: func(r *http.Request) bool { return true }, // Allow all origins 19 | } 20 | 21 | type ProxyServer struct { } 22 | 23 | func NewProxyServer() *ProxyServer { 24 | return &ProxyServer{ } 25 | } 26 | 27 | func (p *ProxyServer) Id() int { 28 | return config.PROXY_SERVER_ID 29 | } 30 | 31 | func (prox *ProxyServer) Start(p proxy.IProxy) error { 32 | factory := ws.NewWSProducer(p.Context()) 33 | e := echo.New() 34 | 35 | e.GET("/health", func(c echo.Context) error { 36 | return nil 37 | }) 38 | 39 | e.GET("/socket", func(c echo.Context) error { 40 | // Upgrade the HTTP connection to a WebSocket connection 41 | conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) 42 | if err != nil { 43 | slog.Error("WebSocket upgrade error:", "error", err) 44 | return err 45 | } 46 | 47 | ws := factory.NewWS(conn) 48 | p.AddInterceptor(ws) 49 | return nil 50 | }) 51 | 52 | url := fmt.Sprintf("0.0.0.0:%d", p.Context().Port) 53 | if err := e.Start(url); err != nil && !errors.Is(err, http.ErrServerClosed) { 54 | slog.Error("echo server crashed", "error", err) 55 | } 56 | } 57 | 58 | func (p *ProxyServer) Name() string { return "ProxyServer" } 59 | func (p *ProxyServer) Close() error { 60 | return nil 61 | } 62 | 63 | 64 | -------------------------------------------------------------------------------- /auth-proxy/pkg/ws/ws.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "sync" 9 | "sync/atomic" 10 | 11 | "github.com/gorilla/websocket" 12 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/data" 13 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/config" 14 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/protocol" 15 | "vim-guys.theprimeagen.tv/auth-proxy/pkg/proxy" 16 | ) 17 | 18 | 19 | type WSFactory struct { 20 | websocketId atomic.Int64 21 | context *config.ProxyContext 22 | logger *slog.Logger 23 | } 24 | 25 | type WS struct { 26 | conn *websocket.Conn 27 | closed bool 28 | websocketId int 29 | mutex sync.Mutex 30 | context *config.ProxyContext 31 | proxy proxy.IProxy 32 | logger *slog.Logger 33 | } 34 | 35 | func NewWSProducer(c *config.ProxyContext) *WSFactory { 36 | factory := &WSFactory{ 37 | websocketId: atomic.Int64{}, 38 | context: c, 39 | logger: slog.Default().With("area", "ws-factory"), 40 | } 41 | factory.websocketId.Store(1000) 42 | return factory 43 | } 44 | 45 | 46 | func (p *WSFactory) NewWS(conn *websocket.Conn) *WS { 47 | id := int(p.websocketId.Add(1)) 48 | return &WS{ 49 | conn: conn, 50 | closed: false, 51 | websocketId: id, 52 | mutex: sync.Mutex{}, 53 | context: p.context, 54 | logger: slog.Default().With("area", fmt.Sprintf("ws-%d", id)), 55 | } 56 | } 57 | 58 | func (w *WS) Id() int { 59 | return w.websocketId 60 | } 61 | 62 | func (w *WS) Name() string { 63 | return "websocket" 64 | } 65 | 66 | func (w *WS) Start(p proxy.IProxy) error { 67 | if !w.context.HasDatabase() { 68 | return fmt.Errorf("unable to create a websocket connection without a database. unable to perform authentication.") 69 | } 70 | w.proxy = p 71 | err := w.authenticate() 72 | err2 := w.ToClient(protocol.Auth(err == nil, w.websocketId)) 73 | if err != nil || err2 != nil { 74 | w.logger.Debug("unable to authenticate websocket client", "id", w.Id(), "send error", err) 75 | w.Close() 76 | } 77 | 78 | // listen for messages and pass them to the game 79 | for { 80 | frame, err := w.next() 81 | if err != nil { 82 | slog.Debug("websocket errored", "error", err) 83 | w.Close() 84 | break 85 | } 86 | slog.Debug("received frame", "frame", frame, "id", w.Id()) 87 | 88 | // TODO filter for things the proxy can understand (stat requests, game quitting, etc etc) 89 | // TODO pass the rest that make sense to the game 90 | p.PushToGame(frame, w) 91 | } 92 | return nil 93 | } 94 | 95 | func (w *WS) ToClient(frame *protocol.ProtocolFrame) error { 96 | // TODO lets see if i can keep this 97 | // I may have to do some magic and probably rename "Original" into frame data 98 | return w.conn.WriteMessage(websocket.BinaryMessage, frame.Frame()) 99 | } 100 | 101 | func (w *WS) next() (*protocol.ProtocolFrame, error) { 102 | for { 103 | t, data, err := w.conn.ReadMessage() 104 | w.logger.Info("msg received", "type", t, "data length", len(data), "err", err) 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | if t != websocket.BinaryMessage { 110 | continue 111 | } 112 | 113 | frame, err := protocol.FromData(data, w.websocketId) 114 | w.logger.Info("msg parsed", "frame", frame, "error", err) 115 | return frame, err 116 | } 117 | } 118 | 119 | func (w *WS) Close() error { 120 | if w.proxy != nil { 121 | w.proxy.RemoveInterceptor(w) 122 | } 123 | 124 | w.mutex.Lock() 125 | defer w.mutex.Unlock() 126 | w.closed = true 127 | w.conn.Close() 128 | return nil 129 | } 130 | 131 | func (w *WS) authenticate() error { 132 | ctx, cancel := context.WithTimeout(context.Background(), w.context.WS.AuthenticationTimeout) 133 | next := make(chan *protocol.ProtocolFrame, 1) 134 | go func() { 135 | data, err := w.next() 136 | if err == nil { 137 | next <- data 138 | } 139 | }() 140 | 141 | select { 142 | case <-ctx.Done(): 143 | cancel() 144 | return errors.New("socket didn't respond in time") 145 | case msg := <-next: 146 | cancel() 147 | if msg.Type != protocol.Authenticate { 148 | return fmt.Errorf("expected authentication packet but received: %d", msg.Type) 149 | } 150 | 151 | token := string(msg.Data) 152 | if !data.AccountExists(w.context, token) { 153 | return fmt.Errorf("Failed to select user_mapping") 154 | } 155 | } 156 | 157 | return nil 158 | } 159 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module vim-guys.theprimeagen.tv 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/gorilla/websocket v1.5.3 7 | github.com/jmoiron/sqlx v1.4.0 8 | github.com/joho/godotenv v1.5.1 9 | github.com/labstack/echo/v4 v4.13.3 10 | github.com/stretchr/testify v1.10.0 11 | github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d 12 | ) 13 | 14 | require ( 15 | github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 16 | github.com/coder/websocket v1.8.12 // indirect 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/labstack/gommon v0.4.2 // indirect 19 | github.com/mattn/go-colorable v0.1.13 // indirect 20 | github.com/mattn/go-isatty v0.0.20 // indirect 21 | github.com/pmezard/go-difflib v1.0.0 // indirect 22 | github.com/valyala/bytebufferpool v1.0.0 // indirect 23 | github.com/valyala/fasttemplate v1.2.2 // indirect 24 | golang.org/x/crypto v0.31.0 // indirect 25 | golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect 26 | golang.org/x/net v0.33.0 // indirect 27 | golang.org/x/sys v0.28.0 // indirect 28 | golang.org/x/text v0.21.0 // indirect 29 | gopkg.in/yaml.v3 v3.0.1 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= 4 | github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= 5 | github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= 6 | github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 10 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 11 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 12 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 13 | github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= 14 | github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= 15 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 16 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 17 | github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= 18 | github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 19 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 20 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 21 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 22 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 23 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 24 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 25 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 26 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 27 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 28 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 29 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 33 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 34 | github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d h1:dOMI4+zEbDI37KGb0TI44GUAwxHF9cMsIoDTJ7UmgfU= 35 | github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= 36 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 37 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 38 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 39 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 40 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 41 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 42 | golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= 43 | golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= 44 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 45 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 46 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 49 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 50 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 51 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 52 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 53 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 54 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 55 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 56 | -------------------------------------------------------------------------------- /lua/vim-guys/init.lua: -------------------------------------------------------------------------------- 1 | local reload = require("vim-guys.reload") 2 | reload.reload_all() 3 | 4 | local ws = require("vim-guys.socket") 5 | local frame = require("vim-guys.socket.frame") 6 | local test_utils = require("vim-guys.test_utils") 7 | 8 | --- @type WS | nil 9 | Client = Client or nil 10 | 11 | if Client ~= nil then 12 | pcall(Client.close, Client) 13 | end 14 | 15 | Client = ws.connect("dev.vimguys.theprimeagen.com", 80) 16 | Client:on_status_change(function (s) 17 | print("status change", s) 18 | if s == "connected" then 19 | local auth = frame.authentication("07669e6d-2857-486a-8208-ce64172875f7") 20 | print("sending message", test_utils.to_hex_string(auth)) 21 | Client:msg(auth) 22 | end 23 | end) 24 | Client:on_action(function(s) 25 | print("message received", test_utils.to_hex_string(s)) 26 | end) 27 | -------------------------------------------------------------------------------- /lua/vim-guys/reload.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local vim_guys = "vim-guys" 4 | M.reload_all = function() 5 | for module_name in pairs(package.loaded) do 6 | if module_name:sub(1, #vim_guys) == vim_guys then 7 | package.loaded[module_name] = nil 8 | end 9 | end 10 | end 11 | 12 | return M 13 | 14 | 15 | -------------------------------------------------------------------------------- /lua/vim-guys/socket/frame.lua: -------------------------------------------------------------------------------- 1 | local ws = require("vim-guys.socket") 2 | 3 | -- PROTOCOL 4 | -- [ version(2) | type(2) | len(2) | player_id(4) | game_id(4) | data(len) ] 5 | 6 | local VERSION = 1 7 | local HEADER_LENGTH = 2 + 2 + 2 + 4 + 4; 8 | local VERSION_OFFSET = 1 9 | local TYPE_OFFSET = 3 10 | local LEN_OFFSET = 5 11 | local DATA_OFFSET = HEADER_LENGTH + 1 12 | 13 | ---@enum Type 14 | local Type = { 15 | Authentication = 1, 16 | } 17 | 18 | -- Create a reverse lookup table (optional, for efficiency with large enums) 19 | local typeValues = {} 20 | for _, v in pairs(Type) do 21 | typeValues[v] = true 22 | end 23 | 24 | setmetatable(Type, { 25 | __index = Type, 26 | --- @param t Type 27 | to_string = function(t) 28 | if t == Type.Authentication then 29 | return "Authentication" 30 | end 31 | return "Unknown" 32 | end, 33 | 34 | --- @param t number 35 | --- @return boolean 36 | is_enum = function(t) 37 | return typeValues[t] or false 38 | end, 39 | 40 | __newindex = function() error("Cannot modify enum Colors") end, 41 | }) 42 | 43 | local _0_str_32 = "\0\0\0\0" 44 | --- @param num number 45 | --- @return string 46 | local function to_big_endian_16(num) 47 | assert(num < 65536, "number bigger than 2^16") 48 | local highByte = math.floor(num / 256) 49 | local lowByte = num % 256 50 | return string.char(highByte, lowByte) 51 | end 52 | 53 | --- @param data string 54 | --- @return number 55 | local function big_endian_16_to_num(data) 56 | assert(#data == 2, "big_endian_16_to_num requires str len 2") 57 | 58 | local high_byte = data:sub(1, 1):byte(1, 1) * 256 59 | local low_byte = data:sub(2, 2):byte(1, 1) 60 | 61 | return high_byte + low_byte 62 | end 63 | 64 | --- @class ProtocolFrame 65 | --- @field type Type 66 | --- @field len number 67 | --- @field data string 68 | local ProtocolFrame = {} 69 | ProtocolFrame.__index = ProtocolFrame 70 | 71 | --- @param type Type 72 | ---@param data string 73 | function ProtocolFrame:new(type, data) 74 | return setmetatable({ 75 | type = type, 76 | len = #data, 77 | data = data, 78 | }, self) 79 | end 80 | 81 | --- @param data_str string 82 | --- @return ProtocolFrame 83 | function ProtocolFrame:from_string(data_str) 84 | local version = big_endian_16_to_num(data_str:sub(VERSION_OFFSET, VERSION_OFFSET + 1)) 85 | assert(version == VERSION, "unable to communicate with server, version mismatch. please update your client") 86 | 87 | local type = big_endian_16_to_num(data_str:sub(TYPE_OFFSET, TYPE_OFFSET + 1)) 88 | local len = big_endian_16_to_num(data_str:sub(LEN_OFFSET, LEN_OFFSET + 1)) 89 | local data = data_str:sub(DATA_OFFSET) 90 | 91 | assert(#data == len, "malformed packet received", "data length", #data, "len expected", len) 92 | 93 | return setmetatable({ 94 | type = type, 95 | len = len, 96 | data = data, 97 | }, self) 98 | end 99 | 100 | --- @return string 101 | function ProtocolFrame:to_frame() 102 | local version = to_big_endian_16(VERSION) 103 | local type_str = to_big_endian_16(self.type) 104 | local len = to_big_endian_16(self.len) 105 | 106 | local header = version .. type_str .. len .. _0_str_32 .. _0_str_32 107 | return header .. self.data 108 | end 109 | 110 | --- @param token string 111 | local function authentication(token) 112 | local frame = ProtocolFrame:new(Type.Authentication, token):to_frame() 113 | print("frame produced", #frame) 114 | return frame 115 | end 116 | 117 | return { 118 | Type = Type, 119 | authentication = authentication, 120 | to_big_endian_16 = to_big_endian_16, 121 | big_endian_16_to_num = big_endian_16_to_num, 122 | ProtocolFrame = ProtocolFrame, 123 | } 124 | 125 | 126 | -------------------------------------------------------------------------------- /lua/vim-guys/socket/frame_spec.lua: -------------------------------------------------------------------------------- 1 | local eq = assert.are.same 2 | local frame = require("vim-guys.socket.frame") 3 | 4 | describe("frame", function() 5 | it("testing the big endian translations!", function() 6 | local test = "EA" 7 | local to = frame.big_endian_16_to_num(test) 8 | local from = frame.to_big_endian_16(to) 9 | eq(test, from) 10 | end) 11 | 12 | it("protocol frame", function() 13 | local p = frame.ProtocolFrame:new(frame.Type.Authentication, "1234-1234") 14 | local frame_str = p:to_frame() 15 | local out = frame.ProtocolFrame:from_string(frame_str) 16 | 17 | eq(p.data, out.data) 18 | eq(p.len, out.len) 19 | eq(p.type, out.type) 20 | end) 21 | end) 22 | 23 | 24 | -------------------------------------------------------------------------------- /lua/vim-guys/socket/init.lua: -------------------------------------------------------------------------------- 1 | local bit = require("bit") 2 | local uv = vim.loop 3 | __Prevent_reconnect = true 4 | 5 | --- @alias StatusChange "connected" | "disconnected" 6 | --- @alias StatusChangeCB fun(s: StatusChange) 7 | --- @alias ServerStatusChangeCB fun() 8 | --- @alias ServerMsgCB fun(frame: string) 9 | 10 | local PING = 0x9 11 | local PONG = 0xA 12 | local TEXT = 0x1 13 | local BINARY = 0x2 14 | local mask = {0x45, 0x45, 0x45, 0x45} 15 | 16 | ---@param ping WSFrame 17 | ---@return string 18 | local function create_pong_frame(ping) 19 | local fin = 0x80 20 | local opcode = PONG 21 | local mask_bit = 0x00 22 | 23 | local payload_length = #ping.data 24 | local length_field = "" 25 | if payload_length <= 125 then 26 | length_field = string.char(payload_length) 27 | else 28 | error("unable to create pong with a large amount of data") 29 | end 30 | 31 | return string.char(fin + opcode) .. mask_bit .. length_field .. ping 32 | end 33 | 34 | 35 | local function complete_header(header) 36 | -- Byte 2: Mask bit and initial payload length 37 | local second_byte = string.byte(header, 2) 38 | local payload_length = bit.band(second_byte, 0x7F) -- Lower 7 bits of Byte 2 39 | local required_len = 2 40 | if payload_length == 126 then 41 | required_len = required_len + 2 42 | elseif payload_length == 127 then 43 | return 0, true 44 | end 45 | 46 | local mask_bit = bit.band(second_byte, 0x80) ~= 0 -- Mask bit (MSB of Byte 2) 47 | if mask_bit then 48 | required_len = required_len + 4 49 | end 50 | 51 | return required_len <= #header, nil 52 | end 53 | 54 | --- @class WSFrame 55 | --- @field data string 56 | --- @field opcode number 57 | --- @field _len number 58 | --- @field _mask string 59 | --- @field _state string 60 | --- @field _buf string 61 | --- @field _fin boolean 62 | --- @field errored boolean 63 | local WSFrame = {} 64 | WSFrame.__index = WSFrame 65 | 66 | function WSFrame:new() 67 | return setmetatable({ 68 | data = "", 69 | errored = false, 70 | _state = "init", 71 | _buf = "", 72 | }, self) 73 | end 74 | 75 | --- @param data string 76 | --- @return string 77 | function WSFrame:_parse_header(data) 78 | local first_byte = string.byte(data, 1) 79 | local second_byte = string.byte(data, 2) 80 | local payload_length = bit.band(second_byte, 0x7F) -- Lower 7 bits of Byte 2 81 | local opcode = bit.band(first_byte, 0x0F) -- Lower 4 bits of Byte 1 82 | 83 | local offset = 2 84 | if payload_length == 126 then 85 | payload_length = bit.lshift(string.byte(data, 3), 8) + string.byte(data, 4) 86 | offset = offset + 2 87 | end 88 | 89 | local mask_bit = bit.band(second_byte, 0x80) ~= 0 -- Mask bit (MSB of Byte 2) 90 | local mask = "" 91 | if mask_bit then 92 | mask = data:sub(offset + 1, offset + 1 + 4) 93 | offset = offset + 4 94 | end 95 | 96 | self._fin = bit.band(first_byte, 0x80) ~= 0 97 | self._len = payload_length 98 | self._mask = mask 99 | self.opcode = opcode 100 | 101 | return data:sub(offset + 1) 102 | end 103 | 104 | function WSFrame:done() 105 | return self._state == "done" 106 | end 107 | 108 | function WSFrame:mask() 109 | return self._mask 110 | end 111 | 112 | --- @param txt string 113 | --- @return string 114 | function WSFrame.text_frame(txt) 115 | local payload_length = #txt 116 | 117 | local first_byte = bit.band(BINARY, 0x0F) 118 | first_byte = bit.bor(first_byte, 0x80) 119 | 120 | local out_string = string.char(first_byte) 121 | 122 | if payload_length > 125 then 123 | local len = 126 124 | len = bit.bor(len, 0x80) 125 | out_string = out_string .. string.char(len) 126 | 127 | local high_byte = bit.rshift(payload_length, 8) 128 | local low_byte = bit.band(payload_length, 0xFF) 129 | out_string = out_string .. string.char(high_byte) 130 | out_string = out_string .. string.char(low_byte) 131 | else 132 | local len = payload_length 133 | len = bit.bor(len, 0x80) 134 | out_string = out_string .. string.char(len) 135 | end 136 | 137 | for i = 1, #mask do 138 | out_string = out_string .. string.char(mask[i]) 139 | end 140 | 141 | local out_txt = "" 142 | for i = 1, #txt do 143 | local mask_byte = mask[i % 4 + 1] 144 | out_txt = out_txt .. string.char(bit.bxor(txt:byte(i, i), mask_byte)) 145 | end 146 | 147 | out_string = out_string .. out_txt 148 | return out_string 149 | end 150 | 151 | --- @param data string 152 | --- @return string 153 | function WSFrame:push(data) 154 | data = self._buf .. data 155 | self._buf = "" 156 | 157 | while self._state ~= "done" and #data > 0 do 158 | if self._state == "init" then 159 | local finished, err = complete_header(data) 160 | if err ~= nil then 161 | self.errored = true 162 | end 163 | 164 | if not finished then 165 | self._buf = self._buf .. data 166 | return "" 167 | end 168 | 169 | data = self:_parse_header(data) 170 | self._state = "body" 171 | elseif self._state == "body" then 172 | if self._len <= #data then 173 | self.data = data:sub(1, self._len) 174 | data = data:sub(self._len + 1) 175 | if self._fin then 176 | self._state = "done" 177 | else 178 | error("I haven't programmed in multiframe frames") 179 | end 180 | end 181 | end 182 | end 183 | 184 | return data 185 | end 186 | 187 | --- @class TCPSocket 188 | --- @field close fun(self: TCPSocket) 189 | --- @field connect fun(self: TCPSocket, addr: string, port: number, cb: fun(e: unknown)) 190 | --- @field is_closing fun(self: TCPSocket): boolean 191 | --- @field write fun(self: TCPSocket, msg: string) 192 | --- @field read_start fun(self: TCPSocket, cb: fun(err: unknown, data: string)) 193 | 194 | --- @class WS 195 | --- @field host string 196 | --- @field port number 197 | --- @field status StatusChange 198 | --- @field _upgraded boolean 199 | --- @field _running boolean 200 | --- @field _client nil | TCPSocket i don't know what this is suppose to be so i made my own 201 | --- @field _currentFrame nil | WSFrame i don't know what this is suppose to be so i made my own 202 | --- @field _on_messages table 203 | --- @field _on_status_change table 204 | --- @field _queued_messages table 205 | local WS = {} 206 | WS.__index = WS 207 | 208 | ---@param host string 209 | ---@param port number 210 | ---@return WS 211 | function WS:new(host, port) 212 | return setmetatable({ 213 | host = host, 214 | port = port, 215 | status = "disconnected", 216 | _upgraded = false, 217 | _currentFrame = WSFrame:new(), 218 | _client = nil, 219 | _running = false, 220 | _on_messages = {}, 221 | _on_status_change = {}, 222 | _queued_messages = {}, 223 | }, self) 224 | end 225 | 226 | --- @param status StatusChange 227 | function WS:_status_change(status) 228 | self.status = status 229 | for _, cb in ipairs(self._on_status_change) do 230 | cb(status) 231 | end 232 | end 233 | 234 | --- @param txt string 235 | function WS:msg(txt) 236 | if self._upgraded == false then 237 | table.insert(self._queued_messages, txt) 238 | return 239 | end 240 | self._client:write(WSFrame.text_frame(txt)) 241 | end 242 | 243 | function WS:_flush() 244 | if self._upgraded == false then 245 | error("called _flush but i am not upgraded... wtf") 246 | end 247 | 248 | for _, value in ipairs(self._queued_messages) do 249 | self._client:write(WSFrame.text_frame(value)) 250 | end 251 | self._queued_messages = {} 252 | end 253 | 254 | function WS:close() 255 | self._running = false 256 | if self._client ~= nil then 257 | self._client:close() 258 | self:_status_change("disconnected") 259 | end 260 | end 261 | 262 | function WS:_connect() 263 | self._running = true 264 | 265 | -- Handle cleanup when the Neovim process exits 266 | vim.api.nvim_create_autocmd("VimLeavePre", { 267 | callback = function() 268 | if self._client ~= nil and not self._client:is_closing() then 269 | self._client:close() 270 | self:_status_change("disconnected") 271 | end 272 | end, 273 | }) 274 | 275 | self:_run_connect() 276 | end 277 | 278 | function WS:_reconnect() 279 | self._upgraded = false 280 | if not self._running then 281 | return 282 | end 283 | 284 | if self._client ~= nil then 285 | self._client:close() 286 | end 287 | 288 | print("server connection broken... reconnecting in 5 seconds") 289 | vim.defer_fn(function() 290 | if __Prevent_reconnect then 291 | return 292 | end 293 | 294 | self._client = nil 295 | self:_run_connect() 296 | end, 50) 297 | end 298 | 299 | --- @param msg WSFrame 300 | function WS:_emit(msg) 301 | if msg.opcode == PING then 302 | local out = create_pong_frame(msg) 303 | self._client:write(out) 304 | elseif msg.opcode == BINARY then 305 | local mask = msg:mask() 306 | if #mask > 0 then 307 | error("i haven't implemented mask... wtf server") 308 | end 309 | 310 | for _, cb in ipairs(self._on_messages) do 311 | cb(msg.data) 312 | end 313 | else 314 | error("i don't currently support this frame.. what is this? " .. vim.inspect(msg)) 315 | end 316 | end 317 | 318 | function WS:_run() 319 | 320 | local ws_upgrade = { 321 | "GET /socket HTTP/1.1", 322 | string.format("Host: %s:%d", self.host, self.port), 323 | string.format("origin: http://%s:%d", self.host, self.port), 324 | "Upgrade: websocket", 325 | "Connection: Upgrade", 326 | "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", 327 | "Sec-WebSocket-Version: 13", 328 | "", 329 | "" 330 | } 331 | 332 | -- Send a message to the server 333 | self._client:write(table.concat(ws_upgrade, "\r\n")) 334 | 335 | -- Read data from the server 336 | self._client:read_start(function(err, data) 337 | if err then 338 | print("Read error:", err) 339 | return 340 | end 341 | 342 | if data then 343 | if self._upgraded then 344 | local remaining = data 345 | 346 | while #remaining > 0 do 347 | remaining = self._currentFrame:push(remaining) 348 | if self._currentFrame.errored then 349 | error("OH NO") 350 | self:_reconnect() 351 | return 352 | end 353 | if self._currentFrame:done() then 354 | self:_emit(self._currentFrame) 355 | self._currentFrame = WSFrame:new() 356 | end 357 | end 358 | else 359 | local str = "HTTP/1.1 101 Switching Protocols" 360 | local sub = data:sub(1, #str) 361 | self._upgraded = str == sub 362 | if self._upgraded then 363 | self:_status_change("connected") 364 | self:_flush() 365 | else 366 | print("upgrade protocol was unsuccessful...") 367 | self:_reconnect() 368 | end 369 | end 370 | else 371 | self:_reconnect() 372 | end 373 | end) 374 | end 375 | 376 | function WS:_run_connect() 377 | -- Resolve the hostname 378 | uv.getaddrinfo(self.host, nil, {}, function(err, res) 379 | if err then 380 | error("DNS resolution error for WS:", err) 381 | return 382 | end 383 | 384 | -- Extract the first resolved address 385 | local resolved_address = res[1].addr 386 | self._client = uv.new_tcp() 387 | print("resolved addr", resolved_address) 388 | 389 | -- Connect to the resolved address and port 390 | self._client:connect(resolved_address, self.port, function(e) 391 | if not e then 392 | self:_run() 393 | else 394 | print("error occurred", e) 395 | self:_reconnect() 396 | end 397 | end) 398 | end) 399 | end 400 | 401 | --- @param key string 402 | function WS:authorize(key) 403 | -- From golang def... probably need to look into protobuf 404 | -- all comes are in a type box as well with the type of message 405 | -- 406 | -- type SocketAuth struct { 407 | -- Type SocketAuthType 408 | -- Key string 409 | -- } 410 | 411 | local data = vim.json.encode({ 412 | type = "socket-auth", 413 | data = { 414 | type = "admin", 415 | key = key, 416 | }, 417 | }) 418 | 419 | self:msg(data) 420 | end 421 | 422 | 423 | --- @param cb StatusChangeCB 424 | function WS:on_status_change(cb) 425 | table.insert(self._on_status_change, cb) 426 | cb(self.status) 427 | end 428 | 429 | --- @param cb ServerMsgCB 430 | function WS:on_action(cb) 431 | table.insert(self._on_messages, cb) 432 | end 433 | 434 | ---@param host string 435 | ---@param port number 436 | ---@return WS 437 | local connect = function(host, port) 438 | local ws = WS:new(host, port) 439 | ws:_connect() 440 | return ws 441 | end 442 | 443 | return { 444 | connect = connect, 445 | WSFrame = WSFrame, 446 | } 447 | 448 | 449 | -------------------------------------------------------------------------------- /lua/vim-guys/test_utils.lua: -------------------------------------------------------------------------------- 1 | 2 | local M = {} 3 | 4 | --- @param num number[] | string 5 | function M.to_hex_string(numsOrString) 6 | local str = "0x" 7 | if type(numsOrString) == "table" then 8 | for _, n in ipairs(num) do 9 | str = str .. string.format("%02x", n) 10 | end 11 | elseif type(numsOrString) == "string" then 12 | print("printing string", #numsOrString) 13 | for i = 1, #numsOrString do 14 | local part = string.format("%02x", numsOrString:byte(i, i)) 15 | print(" part:", part) 16 | str = str .. part 17 | end 18 | print("done", #str) 19 | end 20 | 21 | return str 22 | end 23 | 24 | return M 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /lua/vim-guys/window/float.lua: -------------------------------------------------------------------------------- 1 | 2 | --- @class Float 3 | local Float = {} 4 | Float.__index = Float 5 | 6 | function Float:new() 7 | end 8 | 9 | --- @param width number 10 | ---@param height number 11 | ---@return boolean if the recalculation is successful 12 | function Float:recalculate(width, height) 13 | 14 | return true 15 | end 16 | 17 | return Float 18 | 19 | -------------------------------------------------------------------------------- /lua/vim-guys/window/init.lua: -------------------------------------------------------------------------------- 1 | 2 | --- @class Layout 3 | --- @field 4 | local Layout = {} 5 | Layout.__index = Layout 6 | 7 | function Layout:menu() 8 | end 9 | 10 | function Layout:gameplay() 11 | end 12 | 13 | function Layout:billboard() 14 | end 15 | 16 | function Layout:close_billboard() 17 | end 18 | 19 | function Layout:display_message() 20 | end 21 | 22 | function Layout:close() 23 | end 24 | 25 | return { 26 | new = function() 27 | end 28 | } 29 | -------------------------------------------------------------------------------- /lua/vim-guys/window/win.lua: -------------------------------------------------------------------------------- 1 | 2 | --- @class Window 3 | --- @field rows number 4 | --- @field cols number 5 | 6 | --- @class Position 7 | --- @field row number 8 | --- @field col number 9 | 10 | -------------------------------------------------------------------------------- /scripts/tests/minimal.vim: -------------------------------------------------------------------------------- 1 | set noswapfile 2 | set rtp+=. 3 | set rtp+=../plenary.nvim 4 | runtime! plugin/plenary.vim 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /sst.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { profile } from "console"; 4 | 5 | export default $config({ 6 | app(input) { 7 | return { 8 | name: "vim-guys", 9 | removal: input?.stage === "production" ? "retain" : "remove", 10 | protect: ["production"].includes(input?.stage), 11 | home: "aws", 12 | providers: { cloudflare: "5.49.1" }, 13 | }; 14 | }, 15 | async run() { 16 | const vpc = new sst.aws.Vpc("Vpc", {}); 17 | const cluster = new sst.aws.Cluster("Cluster", { 18 | vpc, 19 | }); 20 | 21 | const prod = $app.stage === "production" 22 | const domain = prod 23 | ? "vimguys.theprimeagen.com" 24 | : `${$app.stage}.vimguys.theprimeagen.com`; 25 | 26 | const ap = new sst.aws.Service("AuthProxy", { 27 | // TODO look at usage logs 28 | environment: { 29 | TURSO_DATABASE_URL: new sst.Secret("TURSO_DATABASE_URL").value, 30 | TURSO_AUTH_TOKEN: new sst.Secret("TURSO_AUTH_TOKEN").value, 31 | }, 32 | scaling: { 33 | min: 1, 34 | max: 16, 35 | }, 36 | image: { 37 | context: "./auth-proxy/", 38 | }, 39 | wait: true, 40 | cluster, 41 | loadBalancer: { 42 | domain: { 43 | name: domain, 44 | dns: sst.cloudflare.dns({ 45 | proxy: prod, 46 | }), 47 | }, 48 | rules: [ 49 | { listen: "80/http", forward: "42000/http" }, 50 | { listen: "443/https", forward: "42000/http" }, 51 | ], 52 | } 53 | }); 54 | 55 | /* 56 | const vg = new sst.aws.Service("VimGuys", { 57 | // TODO set these to make it so i can run ~1000 games 58 | image: { 59 | context: "./vim-guys/", 60 | }, 61 | wait: true, 62 | cluster, 63 | }); 64 | */ 65 | }, 66 | }); 67 | 68 | -------------------------------------------------------------------------------- /test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | get_vmrss() { 3 | if [[ -z "$1" || ! -d "/proc/$1" ]]; then 4 | echo "Usage: get_vmrss " 5 | return 1 6 | fi 7 | 8 | local vmrss=$(grep -i 'VmRSS' /proc/$1/status 2>/dev/null | awk '{print $2, $3}') 9 | if [[ -n "$vmrss" ]]; then 10 | echo "VmRSS for PID $1: $vmrss" 11 | else 12 | echo "VmRSS information not available for PID $1" 13 | fi 14 | } 15 | 16 | -------------------------------------------------------------------------------- /vim-guys/.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | # sst 3 | .sst -------------------------------------------------------------------------------- /vim-guys/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use official Golang image to build the app 2 | FROM golang:1.24-alpine as builder 3 | 4 | WORKDIR /app 5 | COPY . . 6 | 7 | # Build the Go app 8 | RUN go mod download 9 | RUN go build -o main 10 | 11 | # Create a minimal image for deployment 12 | FROM alpine:latest 13 | WORKDIR /root/ 14 | COPY --from=builder /app/main . 15 | EXPOSE 42000 16 | 17 | CMD ["./main"] 18 | 19 | --------------------------------------------------------------------------------