├── .github └── workflows │ └── fly-deploy.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── README.md ├── TODO.md ├── cmd ├── api-server │ └── main.go ├── dial │ └── main.go ├── dummy-client │ └── main.go ├── log-parser │ └── main.go ├── matchmaking │ └── main.go ├── origin-server-test │ └── main.go ├── reverse-proxy-test │ └── main.go ├── sim │ ├── batch │ │ └── main.go │ ├── long-running │ │ └── main.go │ └── simple │ │ └── main.go └── test │ └── main.go ├── e2e-tests ├── data │ ├── no_server │ ├── no_server-shm │ └── no_server-wal ├── matchmaking_test.go ├── run │ ├── configs │ │ └── no_server │ └── main.go └── sim │ ├── assert.go │ ├── connections.go │ ├── create_env.go │ ├── sim.go │ ├── state.go │ └── utils.go ├── fly.toml ├── go.mod ├── go.sum ├── justfile ├── main.just ├── pkg ├── am-proxy │ ├── am-proxy-config.go │ ├── am-proxy.go │ ├── game.go │ ├── matchmaking.go │ └── net.go ├── api │ ├── client.go │ ├── server.go │ └── utils.go ├── assert │ └── assert.go ├── cmd │ └── cmd.go ├── ctrlc │ └── ctlrc.go ├── game-server-stats │ ├── sqlite.go │ └── stats.go ├── packet │ ├── legacy-packet.go │ ├── packet.go │ ├── packet_test.go │ └── protocol.md ├── pretty-log │ └── log.go ├── quick-math │ ├── AABB.go │ ├── AABB_test.go │ ├── Vec.go │ └── Vec_test.go ├── server-management │ ├── flyio.go │ ├── local.go │ └── server.go └── utils │ ├── context.go │ ├── pretty.go │ └── writer.go ├── src └── main.rs └── td.Dockerfile /.github/workflows/fly-deploy.yml: -------------------------------------------------------------------------------- 1 | # See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ 2 | 3 | name: Fly Deploy 4 | on: 5 | push: 6 | branches: 7 | - main 8 | jobs: 9 | deploy: 10 | name: Deploy app 11 | runs-on: ubuntu-latest 12 | concurrency: deploy-group # optional: ensure only one action runs at a time 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: superfly/flyctl-actions/setup-flyctl@master 16 | - run: flyctl deploy --remote-only 17 | env: 18 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | /target 3 | err 4 | failed1 5 | out 6 | test 7 | 8 | # Added by cargo 9 | # 10 | # already existing elements were commented out 11 | 12 | #/target 13 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.24.1" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 | 20 | [[package]] 21 | name = "anstream" 22 | version = "0.6.15" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" 25 | dependencies = [ 26 | "anstyle", 27 | "anstyle-parse", 28 | "anstyle-query", 29 | "anstyle-wincon", 30 | "colorchoice", 31 | "is_terminal_polyfill", 32 | "utf8parse", 33 | ] 34 | 35 | [[package]] 36 | name = "anstyle" 37 | version = "1.0.8" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" 40 | 41 | [[package]] 42 | name = "anstyle-parse" 43 | version = "0.2.5" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" 46 | dependencies = [ 47 | "utf8parse", 48 | ] 49 | 50 | [[package]] 51 | name = "anstyle-query" 52 | version = "1.1.1" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" 55 | dependencies = [ 56 | "windows-sys", 57 | ] 58 | 59 | [[package]] 60 | name = "anstyle-wincon" 61 | version = "3.0.4" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" 64 | dependencies = [ 65 | "anstyle", 66 | "windows-sys", 67 | ] 68 | 69 | [[package]] 70 | name = "anyhow" 71 | version = "1.0.89" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" 74 | 75 | [[package]] 76 | name = "autocfg" 77 | version = "1.2.0" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" 80 | 81 | [[package]] 82 | name = "backtrace" 83 | version = "0.3.74" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" 86 | dependencies = [ 87 | "addr2line", 88 | "cfg-if", 89 | "libc", 90 | "miniz_oxide", 91 | "object", 92 | "rustc-demangle", 93 | "windows-targets", 94 | ] 95 | 96 | [[package]] 97 | name = "bitflags" 98 | version = "2.5.0" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" 101 | 102 | [[package]] 103 | name = "bytes" 104 | version = "1.7.2" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" 107 | 108 | [[package]] 109 | name = "cfg-if" 110 | version = "1.0.0" 111 | source = "registry+https://github.com/rust-lang/crates.io-index" 112 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 113 | 114 | [[package]] 115 | name = "clap" 116 | version = "4.5.18" 117 | source = "registry+https://github.com/rust-lang/crates.io-index" 118 | checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3" 119 | dependencies = [ 120 | "clap_builder", 121 | "clap_derive", 122 | ] 123 | 124 | [[package]] 125 | name = "clap_builder" 126 | version = "4.5.18" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b" 129 | dependencies = [ 130 | "anstream", 131 | "anstyle", 132 | "clap_lex", 133 | "strsim", 134 | ] 135 | 136 | [[package]] 137 | name = "clap_derive" 138 | version = "4.5.18" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" 141 | dependencies = [ 142 | "heck", 143 | "proc-macro2", 144 | "quote", 145 | "syn", 146 | ] 147 | 148 | [[package]] 149 | name = "clap_lex" 150 | version = "0.7.2" 151 | source = "registry+https://github.com/rust-lang/crates.io-index" 152 | checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" 153 | 154 | [[package]] 155 | name = "colorchoice" 156 | version = "1.0.2" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" 159 | 160 | [[package]] 161 | name = "gimli" 162 | version = "0.31.0" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" 165 | 166 | [[package]] 167 | name = "heck" 168 | version = "0.5.0" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 171 | 172 | [[package]] 173 | name = "hermit-abi" 174 | version = "0.3.9" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" 177 | 178 | [[package]] 179 | name = "is_terminal_polyfill" 180 | version = "1.70.1" 181 | source = "registry+https://github.com/rust-lang/crates.io-index" 182 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 183 | 184 | [[package]] 185 | name = "libc" 186 | version = "0.2.159" 187 | source = "registry+https://github.com/rust-lang/crates.io-index" 188 | checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" 189 | 190 | [[package]] 191 | name = "lock_api" 192 | version = "0.4.12" 193 | source = "registry+https://github.com/rust-lang/crates.io-index" 194 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 195 | dependencies = [ 196 | "autocfg", 197 | "scopeguard", 198 | ] 199 | 200 | [[package]] 201 | name = "memchr" 202 | version = "2.7.4" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 205 | 206 | [[package]] 207 | name = "miniz_oxide" 208 | version = "0.8.0" 209 | source = "registry+https://github.com/rust-lang/crates.io-index" 210 | checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" 211 | dependencies = [ 212 | "adler2", 213 | ] 214 | 215 | [[package]] 216 | name = "mio" 217 | version = "1.0.2" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" 220 | dependencies = [ 221 | "hermit-abi", 222 | "libc", 223 | "wasi", 224 | "windows-sys", 225 | ] 226 | 227 | [[package]] 228 | name = "object" 229 | version = "0.36.4" 230 | source = "registry+https://github.com/rust-lang/crates.io-index" 231 | checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" 232 | dependencies = [ 233 | "memchr", 234 | ] 235 | 236 | [[package]] 237 | name = "parking_lot" 238 | version = "0.12.3" 239 | source = "registry+https://github.com/rust-lang/crates.io-index" 240 | checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 241 | dependencies = [ 242 | "lock_api", 243 | "parking_lot_core", 244 | ] 245 | 246 | [[package]] 247 | name = "parking_lot_core" 248 | version = "0.9.10" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 251 | dependencies = [ 252 | "cfg-if", 253 | "libc", 254 | "redox_syscall", 255 | "smallvec", 256 | "windows-targets", 257 | ] 258 | 259 | [[package]] 260 | name = "pin-project-lite" 261 | version = "0.2.14" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" 264 | 265 | [[package]] 266 | name = "proc-macro2" 267 | version = "1.0.79" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" 270 | dependencies = [ 271 | "unicode-ident", 272 | ] 273 | 274 | [[package]] 275 | name = "quote" 276 | version = "1.0.35" 277 | source = "registry+https://github.com/rust-lang/crates.io-index" 278 | checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" 279 | dependencies = [ 280 | "proc-macro2", 281 | ] 282 | 283 | [[package]] 284 | name = "redox_syscall" 285 | version = "0.5.7" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" 288 | dependencies = [ 289 | "bitflags", 290 | ] 291 | 292 | [[package]] 293 | name = "rustc-demangle" 294 | version = "0.1.24" 295 | source = "registry+https://github.com/rust-lang/crates.io-index" 296 | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 297 | 298 | [[package]] 299 | name = "scopeguard" 300 | version = "1.2.0" 301 | source = "registry+https://github.com/rust-lang/crates.io-index" 302 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 303 | 304 | [[package]] 305 | name = "signal-hook-registry" 306 | version = "1.4.2" 307 | source = "registry+https://github.com/rust-lang/crates.io-index" 308 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 309 | dependencies = [ 310 | "libc", 311 | ] 312 | 313 | [[package]] 314 | name = "smallvec" 315 | version = "1.13.2" 316 | source = "registry+https://github.com/rust-lang/crates.io-index" 317 | checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" 318 | 319 | [[package]] 320 | name = "socket2" 321 | version = "0.5.7" 322 | source = "registry+https://github.com/rust-lang/crates.io-index" 323 | checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" 324 | dependencies = [ 325 | "libc", 326 | "windows-sys", 327 | ] 328 | 329 | [[package]] 330 | name = "strsim" 331 | version = "0.11.1" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 334 | 335 | [[package]] 336 | name = "syn" 337 | version = "2.0.57" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "11a6ae1e52eb25aab8f3fb9fca13be982a373b8f1157ca14b897a825ba4a2d35" 340 | dependencies = [ 341 | "proc-macro2", 342 | "quote", 343 | "unicode-ident", 344 | ] 345 | 346 | [[package]] 347 | name = "tokio" 348 | version = "1.40.0" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" 351 | dependencies = [ 352 | "backtrace", 353 | "bytes", 354 | "libc", 355 | "mio", 356 | "parking_lot", 357 | "pin-project-lite", 358 | "signal-hook-registry", 359 | "socket2", 360 | "tokio-macros", 361 | "windows-sys", 362 | ] 363 | 364 | [[package]] 365 | name = "tokio-macros" 366 | version = "2.4.0" 367 | source = "registry+https://github.com/rust-lang/crates.io-index" 368 | checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" 369 | dependencies = [ 370 | "proc-macro2", 371 | "quote", 372 | "syn", 373 | ] 374 | 375 | [[package]] 376 | name = "unicode-ident" 377 | version = "1.0.12" 378 | source = "registry+https://github.com/rust-lang/crates.io-index" 379 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 380 | 381 | [[package]] 382 | name = "utf8parse" 383 | version = "0.2.2" 384 | source = "registry+https://github.com/rust-lang/crates.io-index" 385 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 386 | 387 | [[package]] 388 | name = "vim-arcade" 389 | version = "0.1.0" 390 | dependencies = [ 391 | "anyhow", 392 | "clap", 393 | "tokio", 394 | ] 395 | 396 | [[package]] 397 | name = "wasi" 398 | version = "0.11.0+wasi-snapshot-preview1" 399 | source = "registry+https://github.com/rust-lang/crates.io-index" 400 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 401 | 402 | [[package]] 403 | name = "windows-sys" 404 | version = "0.52.0" 405 | source = "registry+https://github.com/rust-lang/crates.io-index" 406 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 407 | dependencies = [ 408 | "windows-targets", 409 | ] 410 | 411 | [[package]] 412 | name = "windows-targets" 413 | version = "0.52.6" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 416 | dependencies = [ 417 | "windows_aarch64_gnullvm", 418 | "windows_aarch64_msvc", 419 | "windows_i686_gnu", 420 | "windows_i686_gnullvm", 421 | "windows_i686_msvc", 422 | "windows_x86_64_gnu", 423 | "windows_x86_64_gnullvm", 424 | "windows_x86_64_msvc", 425 | ] 426 | 427 | [[package]] 428 | name = "windows_aarch64_gnullvm" 429 | version = "0.52.6" 430 | source = "registry+https://github.com/rust-lang/crates.io-index" 431 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 432 | 433 | [[package]] 434 | name = "windows_aarch64_msvc" 435 | version = "0.52.6" 436 | source = "registry+https://github.com/rust-lang/crates.io-index" 437 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 438 | 439 | [[package]] 440 | name = "windows_i686_gnu" 441 | version = "0.52.6" 442 | source = "registry+https://github.com/rust-lang/crates.io-index" 443 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 444 | 445 | [[package]] 446 | name = "windows_i686_gnullvm" 447 | version = "0.52.6" 448 | source = "registry+https://github.com/rust-lang/crates.io-index" 449 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 450 | 451 | [[package]] 452 | name = "windows_i686_msvc" 453 | version = "0.52.6" 454 | source = "registry+https://github.com/rust-lang/crates.io-index" 455 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 456 | 457 | [[package]] 458 | name = "windows_x86_64_gnu" 459 | version = "0.52.6" 460 | source = "registry+https://github.com/rust-lang/crates.io-index" 461 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 462 | 463 | [[package]] 464 | name = "windows_x86_64_gnullvm" 465 | version = "0.52.6" 466 | source = "registry+https://github.com/rust-lang/crates.io-index" 467 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 468 | 469 | [[package]] 470 | name = "windows_x86_64_msvc" 471 | version = "0.52.6" 472 | source = "registry+https://github.com/rust-lang/crates.io-index" 473 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 474 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "vim-arcade" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | anyhow = "1.0.89" 10 | clap = { version = "4.5.18", features = ["derive"] } 11 | tokio = { version = "1.40.0", features = ["full"] } 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GO_VERSION=1 2 | FROM golang:1.23.0-bookworm as builder 3 | 4 | WORKDIR /usr/src/app 5 | COPY go.mod /usr/src/app 6 | RUN go mod download && go mod verify 7 | COPY cmd cmd 8 | COPY pkg pkg 9 | RUN go build -v -o /run-app /usr/src/app/cmd/matchmaking/main.go 10 | 11 | FROM debian:bookworm 12 | 13 | COPY --from=builder /run-app /usr/local/bin/ 14 | CMD ["run-app"] 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | batman 2 | 3 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ## real todos 2 | * json default printing throughout the program. DEBUG_TYPE should be used for 3 | pretty not the other way around 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /cmd/api-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | 9 | "github.com/joho/godotenv" 10 | "vim-arcade.theprimeagen.com/pkg/api" 11 | "vim-arcade.theprimeagen.com/pkg/assert" 12 | "vim-arcade.theprimeagen.com/pkg/ctrlc" 13 | gameserverstats "vim-arcade.theprimeagen.com/pkg/game-server-stats" 14 | prettylog "vim-arcade.theprimeagen.com/pkg/pretty-log" 15 | ) 16 | 17 | func getId() string { 18 | return os.Getenv("ID") 19 | } 20 | 21 | func main() { 22 | godotenv.Load() 23 | 24 | if os.Getenv("DEBUG_LOG") != "" { 25 | assert.Never("debug log should never be specified for a dummy server") 26 | } 27 | 28 | sqlitePath := os.Getenv("SQLITE") 29 | assert.Assert(sqlitePath != "", "you must provide a sqlite env variable to run the simulation dummy server") 30 | sqlitePath = gameserverstats.EnsureSqliteURI(sqlitePath) 31 | 32 | prettylog.CreateLoggerFromEnv(os.Stderr) 33 | slog.SetDefault(slog.Default().With("process", fmt.Sprintf("DummyServer-%s", getId()))) 34 | 35 | ll := slog.Default().With("area", "dummy-server") 36 | ll.Warn("dummy-server initializing...") 37 | 38 | db := gameserverstats.NewSqlite(sqlitePath) 39 | db.SetSqliteModes() 40 | host, port := api.GetHostAndPort() 41 | 42 | config := gameserverstats.GameServerConfig { 43 | State: gameserverstats.GSStateReady, 44 | Connections: 0, 45 | Load: 0, 46 | Id: getId(), 47 | Host: host, 48 | Port: port, 49 | } 50 | 51 | ll.Info("creating server", "port", port, "host", host) 52 | server := api.NewGameServerRunner(db, config) 53 | ctx, cancel := context.WithCancel(context.Background()) 54 | ctrlc.HandleCtrlC(cancel) 55 | 56 | defer server.Close() 57 | go db.Run(ctx) 58 | go func () { 59 | ll.Info("running server", "port", port, "host", host) 60 | err := server.Run(ctx) 61 | if err != nil { 62 | ll.Error("Game Server Run came returned with an error", "error", err) 63 | cancel() 64 | } 65 | }() 66 | 67 | server.Wait() 68 | cancel() 69 | ll.Error("dummy game server finished") 70 | } 71 | -------------------------------------------------------------------------------- /cmd/dial/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net" 8 | "time" 9 | ) 10 | 11 | type SpoofIp struct { 12 | Port int 13 | Source string 14 | } 15 | 16 | //Network() string // name of the network (for example, "tcp", "udp") 17 | //String() string // string form of address (for example, "192.0.2.1:25", "[2001:db8::1]:80") 18 | func (s *SpoofIp) String() string { 19 | return fmt.Sprintf("%s:%d", s.Source, s.Port) 20 | } 21 | 22 | func (s *SpoofIp) Network() string { 23 | return "tcp" 24 | } 25 | 26 | func main() { 27 | var d net.Dialer 28 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute) 29 | defer cancel() 30 | 31 | addr := net.TCPAddr{IP: net.IPv4(127, 0, 69, 69), Port: 42042} 32 | d.LocalAddr = &addr 33 | 34 | conn, err := d.DialContext(ctx, "tcp", "127.0.42.69:42069") 35 | if err != nil { 36 | log.Fatalf("Failed to dial: %v", err) 37 | } 38 | defer conn.Close() 39 | 40 | if _, err := conn.Write([]byte("Hello, World!")); err != nil { 41 | log.Fatal(err) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /cmd/dummy-client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log/slog" 7 | "os" 8 | 9 | "vim-arcade.theprimeagen.com/pkg/assert" 10 | "vim-arcade.theprimeagen.com/pkg/dummy" 11 | prettylog "vim-arcade.theprimeagen.com/pkg/pretty-log" 12 | ) 13 | 14 | 15 | func main() { 16 | port := uint(0) 17 | flag.UintVar(&port, "port", 0, "the port to connect the dummy client to") 18 | flag.Parse() 19 | 20 | assert.Assert(port > 0, "expected port to be provided", "port", port) 21 | 22 | // TODO logging customization through some sort of config/env 23 | prettylog.SetProgramLevelPrettyLogger(prettylog.NewParams(os.Stderr)) 24 | 25 | client := dummy.NewDummyClient("", uint16(port)) 26 | 27 | err := client.Connect(context.Background()) 28 | if err != nil { 29 | slog.Error("unable to connect client", "err", err) 30 | return 31 | } 32 | 33 | client.WaitForDone() 34 | } 35 | 36 | -------------------------------------------------------------------------------- /cmd/log-parser/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | "os" 9 | "strings" 10 | 11 | "vim-arcade.theprimeagen.com/pkg/assert" 12 | prettylog "vim-arcade.theprimeagen.com/pkg/pretty-log" 13 | ) 14 | 15 | type Log struct { 16 | Process string `json:"process"` 17 | Area string `json:"area"` 18 | Msg string `json:"msg"` 19 | } 20 | 21 | type LogLine struct { 22 | Line string 23 | Log Log 24 | } 25 | 26 | type Filter struct { 27 | process string 28 | area string 29 | msg string 30 | } 31 | 32 | type RoundFilter struct { 33 | currentRound int 34 | expectedRound int 35 | } 36 | 37 | func NewRoundFilter(round int) IFilter { 38 | return &RoundFilter{ 39 | currentRound: -1, 40 | expectedRound: round, 41 | } 42 | } 43 | 44 | func (r *RoundFilter) Filter(line LogLine) bool { 45 | if simRoundFilter.Filter(line) { 46 | r.currentRound = getRound(line) 47 | } 48 | 49 | return r.currentRound == r.expectedRound 50 | } 51 | 52 | func (r *RoundFilter) String() string { 53 | return fmt.Sprintf("RoundFilter(%d)", r.expectedRound) 54 | } 55 | 56 | type IFilter interface { 57 | Filter(line LogLine) bool 58 | String() string 59 | } 60 | 61 | func NewFilter(line string) Filter { 62 | parts := strings.Split(line, ":") 63 | assert.Assert(len(parts) == 3, "poorly formed filter", "filter", line) 64 | 65 | return Filter{ 66 | process: parts[0], 67 | area: parts[1], 68 | msg: parts[2], 69 | } 70 | } 71 | 72 | func (f *Filter) Filter(log LogLine) bool { 73 | process := f.process == "*" || strings.Contains(log.Log.Process, f.process) 74 | area := f.area == "*" || strings.Contains(log.Log.Area, f.area) 75 | msg := f.msg == "*" || strings.Contains(log.Log.Msg, f.msg) 76 | 77 | return process && area && msg 78 | } 79 | 80 | var simRoundFilter = NewFilter("*:*:SimRound") 81 | func getRound(log LogLine) int { 82 | var line map[string]any 83 | err := json.Unmarshal([]byte(log.Line), &line) 84 | assert.NoError(err, "unable to parse sim round") 85 | 86 | return int(line["round"].(float64)) 87 | } 88 | 89 | func (f *Filter) String() string { 90 | return fmt.Sprintf("%s:%s:%s", f.process, f.area, f.msg) 91 | } 92 | 93 | type Parser struct { 94 | reader *bufio.Scanner 95 | filters []IFilter 96 | } 97 | 98 | func NewParser(fh *os.File, filters []IFilter) Parser { 99 | return Parser{ 100 | reader: bufio.NewScanner(fh), 101 | filters: filters, 102 | } 103 | } 104 | 105 | func (p *Parser) Next() *LogLine { 106 | var log *LogLine = nil 107 | 108 | outer: 109 | for p.reader.Scan() { 110 | txt := p.reader.Text() 111 | l := toLog(txt) 112 | 113 | if len(p.filters) == 0 { 114 | log = &l 115 | break 116 | } 117 | 118 | for _, f := range p.filters { 119 | if f.Filter(l) { 120 | log = &l 121 | break outer 122 | } 123 | } 124 | } 125 | 126 | return log 127 | } 128 | 129 | func (p *Parser) String() string { 130 | out := []string{ } 131 | for _, f := range p.filters { 132 | out = append(out, f.String()) 133 | } 134 | 135 | return strings.Join(out, "\n") 136 | } 137 | 138 | func toLog(line string) LogLine { 139 | var log Log 140 | _ = json.Unmarshal([]byte(line), &log) 141 | 142 | return LogLine{Log: log, Line: line} 143 | } 144 | 145 | func toLogs(lines []string) []LogLine { 146 | out := []LogLine{} 147 | for _, line := range lines { 148 | out = append(out, toLog(line)) 149 | } 150 | 151 | return out 152 | } 153 | 154 | func toFilters(lines []string) []IFilter { 155 | out := []IFilter{} 156 | for _, line := range lines { 157 | if line == "" { 158 | continue 159 | } 160 | f := NewFilter(line) 161 | out = append(out, &f) 162 | } 163 | 164 | return out 165 | } 166 | 167 | func p(msg string, count int) { 168 | if !strings.HasSuffix(msg, "\n") { 169 | msg += "\n" 170 | } 171 | 172 | if count > 1 { 173 | fmt.Printf("%d: %s", count, msg) 174 | } else { 175 | fmt.Printf("%s", msg) 176 | } 177 | } 178 | 179 | func isPipedStdin() bool { 180 | info, err := os.Stdin.Stat() 181 | assert.NoError(err, "unable to stat stdin") 182 | return (info.Mode() & os.ModeCharDevice) == 0 183 | } 184 | 185 | func main() { 186 | pretty := false 187 | flag.BoolVar(&pretty, "pretty", false, "to make the logs pretty") 188 | 189 | dedupe := false 190 | flag.BoolVar(&dedupe, "dedupe", false, "dedupe exactly the same logs") 191 | 192 | round := -1 193 | flag.IntVar(&round, "round", -1, "which log round to grab") 194 | 195 | filtersList := "" 196 | flag.StringVar(&filtersList, "filters", "", "the filters") 197 | flag.Parse() 198 | 199 | var fh *os.File = nil 200 | var err error = nil 201 | if isPipedStdin() { 202 | fh = os.Stdin 203 | } else { 204 | fh, err = os.OpenFile(flag.Arg(0), os.O_RDWR|os.O_CREATE, 0644) 205 | } 206 | 207 | assert.NoError(err, "expected contents to be read") 208 | 209 | filtersStrings := strings.Split(filtersList, ",") 210 | filters := toFilters(filtersStrings) 211 | 212 | if round >= 0 { 213 | filters = append(filters, NewRoundFilter(round)) 214 | } 215 | 216 | parser := NewParser(fh, filters) 217 | 218 | prev := "" 219 | count := 0 220 | for { 221 | out := parser.Next() 222 | var toPrint string 223 | var err error = nil 224 | 225 | if out != nil { 226 | if pretty { 227 | var line map[string]any 228 | err := json.Unmarshal([]byte(out.Line), &line) 229 | if err == nil { 230 | toPrint, err = prettylog.PrettyLine(line, prettylog.Colorizer) 231 | } else { 232 | toPrint = out.Line 233 | } 234 | } else { 235 | toPrint = out.Line 236 | } 237 | } else { 238 | break 239 | } 240 | 241 | if toPrint == prev { 242 | count += 1 243 | } else if prev == "" { 244 | prev = toPrint 245 | count = 1 246 | } else { 247 | p(prev, count) 248 | prev = toPrint 249 | count = 1 250 | } 251 | 252 | assert.NoError(err, "pretty print should not error") 253 | } 254 | 255 | p(prev, count) 256 | } 257 | 258 | -------------------------------------------------------------------------------- /cmd/matchmaking/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/joho/godotenv" 10 | "vim-arcade.theprimeagen.com/pkg/ctrlc" 11 | gameserverstats "vim-arcade.theprimeagen.com/pkg/game-server-stats" 12 | "vim-arcade.theprimeagen.com/pkg/pretty-log" 13 | servermanagement "vim-arcade.theprimeagen.com/pkg/server-management" 14 | ) 15 | 16 | 17 | func main() { 18 | err := godotenv.Load() 19 | if err != nil { 20 | slog.Error("unable to load env", "err", err) 21 | return 22 | } 23 | 24 | prettylog.SetProgramLevelPrettyLogger(prettylog.NewParams(os.Stderr)) 25 | slog.SetDefault(slog.Default().With("process", "MatchMaking")) 26 | slog.Error("Hello world") 27 | 28 | port, err := strconv.Atoi(os.Getenv("MM_PORT")) 29 | logger := slog.Default().With("area", "MatchMakingMain") 30 | 31 | if err != nil { 32 | slog.Error("port parsing error", "port", port) 33 | os.Exit(1) 34 | } 35 | 36 | db := gameserverstats.NewSqlite("file:/tmp/sim.db") 37 | db.SetSqliteModes() 38 | local := servermanagement.NewLocalServers(db, servermanagement.ServerParams{ 39 | MaxLoad: 0.9, 40 | }) 41 | mm := matchmaking.NewMatchMakingServer(matchmaking.MatchMakingServerParams{ 42 | Port: port, 43 | GameServer: &local, 44 | }) 45 | 46 | ctx, cancel := context.WithCancel(context.Background()) 47 | ctrlc.HandleCtrlC(cancel) 48 | 49 | go db.Run(ctx) 50 | err = mm.Run(ctx) 51 | 52 | logger.Warn("mm main finished", "error", err) 53 | } 54 | 55 | -------------------------------------------------------------------------------- /cmd/origin-server-test/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net" 7 | "os" 8 | 9 | "vim-arcade.theprimeagen.com/pkg/assert" 10 | "vim-arcade.theprimeagen.com/pkg/dummy" 11 | prettylog "vim-arcade.theprimeagen.com/pkg/pretty-log" 12 | ) 13 | 14 | func main() { 15 | prettylog.SetProgramLevelPrettyLogger(prettylog.NewParams(os.Stderr)) 16 | slog.SetDefault(slog.Default().With("process", "DummyServer")) 17 | 18 | ll := slog.Default().With("area", "origin-server-test") 19 | ll.Warn("origin-server-test initializing...") 20 | 21 | port, err := dummy.GetFreePort() 22 | assert.NoError(err, "cannot get port") 23 | 24 | hostAndPort := fmt.Sprintf(":%d", port) 25 | ll.Warn("origin-server-test port", "port", port) 26 | 27 | l, err := net.Listen("tcp4", hostAndPort) 28 | assert.NoError(err, "cannot listen to port") 29 | 30 | id := 0 31 | 32 | for { 33 | conn, err := l.Accept() 34 | assert.NoError(err, "unable to accept any more connections") 35 | thisId := id 36 | id++ 37 | 38 | bytes := make([]byte, 1024, 1024) 39 | n, err := conn.Read(bytes) 40 | assert.NoError(err, "unable to read from connection") 41 | 42 | ll.Warn("connection data", "id", thisId, "data", string(bytes[:n])) 43 | conn.Write(bytes[:n]) 44 | ll.Warn("closing down connection", "id", thisId) 45 | conn.Close() 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /cmd/reverse-proxy-test/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "net" 10 | "os" 11 | 12 | "vim-arcade.theprimeagen.com/pkg/assert" 13 | "vim-arcade.theprimeagen.com/pkg/dummy" 14 | prettylog "vim-arcade.theprimeagen.com/pkg/pretty-log" 15 | ) 16 | 17 | type Proxied struct { 18 | to net.Conn 19 | from net.Conn 20 | cancel context.CancelFunc 21 | ctx context.Context 22 | } 23 | 24 | func ProxiedFromConn(from net.Conn, addr string) *Proxied { 25 | to, err := net.Dial("tcp4", addr) 26 | assert.NoError(err, "unable to connect to origin server") 27 | 28 | return &Proxied{ 29 | from: from, 30 | to: to, 31 | } 32 | } 33 | 34 | func (p *Proxied) Run(outer context.Context) { 35 | ctx, cancel := context.WithCancel(outer) 36 | go func() { 37 | io.Copy(p.from, p.to) 38 | cancel() 39 | }() 40 | 41 | go func() { 42 | io.Copy(p.to, p.from) 43 | cancel() 44 | }() 45 | 46 | <-ctx.Done() 47 | 48 | p.from.Close() 49 | p.to.Close() 50 | } 51 | 52 | func main() { 53 | // No way to get proxy information... 54 | // hard coded 55 | var proxyPort uint 56 | flag.UintVar(&proxyPort, "port", 0, "the port of the service to proxy too") 57 | flag.Parse() 58 | 59 | assert.Assert(proxyPort != 0, "please provide --port") 60 | toPort := uint16(proxyPort) 61 | 62 | prettylog.SetProgramLevelPrettyLogger(prettylog.NewParams(os.Stderr)) 63 | slog.SetDefault(slog.Default().With("process", "DummyServer")) 64 | 65 | ll := slog.Default().With("area", "reverse-proxy-test") 66 | ll.Warn("reverse-proxy-test initializing...") 67 | 68 | myPort, err := dummy.GetFreePort() 69 | assert.NoError(err, "cannot get port") 70 | 71 | hostAndPort := fmt.Sprintf(":%d", myPort) 72 | ll.Warn("reverse-proxy-test port", "port", myPort) 73 | 74 | l, err := net.Listen("tcp4", hostAndPort) 75 | assert.NoError(err, "cannot listen to port") 76 | 77 | toAddr := fmt.Sprintf(":%d", toPort) 78 | ll.Warn("to server", "addr", toAddr) 79 | 80 | id := 0 81 | 82 | ctx := context.Background() 83 | for { 84 | conn, err := l.Accept() 85 | assert.NoError(err, "unable to accept any more connections") 86 | 87 | newId := id 88 | id++ 89 | ll.Warn("new connection", "id", newId) 90 | proxy := ProxiedFromConn(conn, toAddr) 91 | 92 | go proxy.Run(ctx) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /cmd/sim/batch/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path" 7 | "time" 8 | 9 | "vim-arcade.theprimeagen.com/e2e-tests/sim" 10 | "vim-arcade.theprimeagen.com/pkg/assert" 11 | gameserverstats "vim-arcade.theprimeagen.com/pkg/game-server-stats" 12 | servermanagement "vim-arcade.theprimeagen.com/pkg/server-management" 13 | ) 14 | 15 | func main() { 16 | logger := sim.CreateLogger("batch") 17 | logger.Info("Welcome to costco", "count", 15) 18 | 19 | ctx, cancel := context.WithCancel(context.Background()) 20 | sim.KillContext(cancel) 21 | 22 | cwd, err := os.Getwd() 23 | assert.NoError(err, "unable to get cwd") 24 | p := path.Join(cwd, "e2e-tests/data/no_server") 25 | 26 | state := sim.CreateEnvironment(ctx, p, servermanagement.ServerParams{ 27 | MaxLoad: 0.9, 28 | }) 29 | 30 | logger.Info("Creating batched connections", "count", 15) 31 | clients := state.Factory.CreateBatchedConnections(15) 32 | logger.Info("Finished creating batched connections") 33 | defer cancel() 34 | 35 | sim.AssertClients(&state, clients); 36 | sim.AssertAllClientsSameServer(&state, clients); 37 | sim.AssertConnectionCount(&state, gameserverstats.GameServecConfigConnectionStats{ 38 | Connections: 15, 39 | ConnectionsAdded: 15, 40 | ConnectionsRemoved: 0, 41 | }, time.Second * 5) 42 | } 43 | -------------------------------------------------------------------------------- /cmd/sim/long-running/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "time" 10 | 11 | "vim-arcade.theprimeagen.com/e2e-tests/sim" 12 | "vim-arcade.theprimeagen.com/pkg/assert" 13 | "vim-arcade.theprimeagen.com/pkg/ctrlc" 14 | "vim-arcade.theprimeagen.com/pkg/dummy" 15 | gameserverstats "vim-arcade.theprimeagen.com/pkg/game-server-stats" 16 | "vim-arcade.theprimeagen.com/pkg/matchmaking" 17 | prettylog "vim-arcade.theprimeagen.com/pkg/pretty-log" 18 | servermanagement "vim-arcade.theprimeagen.com/pkg/server-management" 19 | ) 20 | 21 | func createMatchMaking() (servermanagement.LocalServers, *gameserverstats.Sqlite, *matchmaking.MatchMakingServer) { 22 | _, port := dummy.GetHostAndPort() 23 | 24 | path := "/tmp/sim.db" 25 | gameserverstats.ClearSQLiteFiles(path) 26 | 27 | path = gameserverstats.EnsureSqliteURI(path) 28 | db := gameserverstats.NewSqlite(path) 29 | os.Setenv("SQLITE", path) 30 | db.SetSqliteModes() 31 | err := db.CreateGameServerConfigs() 32 | assert.NoError(err, "unable to create game server configs") 33 | 34 | configs, err := db.GetAllGameServerConfigs() 35 | assert.NoError(err, "unable to get server configs") 36 | assert.Assert(len(configs) == 0, "expected the server to be free on configs", "configs", configs) 37 | 38 | local := servermanagement.NewLocalServers(db, servermanagement.ServerParams{ 39 | MaxLoad: 0.9, 40 | }) 41 | 42 | return local, db, matchmaking.NewMatchMakingServer(matchmaking.MatchMakingServerParams{ 43 | Port: port, 44 | GameServer: &local, 45 | }) 46 | } 47 | 48 | func main() { 49 | var inline bool 50 | flag.BoolVar(&inline, "inline", false, "if logging and display output should both go to stdout") 51 | flag.Parse() 52 | 53 | fh := os.Stderr 54 | if inline { 55 | fh = os.Stdout 56 | } 57 | 58 | logger := prettylog.CreateLoggerFromEnv(fh) 59 | 60 | slog.SetDefault(logger.With("process", "sim").With("area", "long-running")) 61 | local, db, mm := createMatchMaking() 62 | ctx, cancel := context.WithCancel(context.Background()) 63 | 64 | defer mm.Close() 65 | go func() { 66 | err := mm.Run(ctx) 67 | if err != nil { 68 | logger.Error("MatchMaking Run exited with an error", "err", err) 69 | } 70 | cancel() 71 | }() 72 | go db.Run(ctx) 73 | go ctrlc.HandleCtrlC(cancel) 74 | mm.WaitForReady(ctx) 75 | s := sim.NewSimulation(sim.SimulationParams{ 76 | Seed: 69, 77 | Rounds: 50000, 78 | Host: "", 79 | Port: uint16(mm.Params.Port), 80 | Stats: db, 81 | StdConnections: 500, 82 | MaxBatchConnectionChange: 25, 83 | TimeToConnectionCountMS: 5000, 84 | ConnectionSleepMinMS: 50, 85 | ConnectionSleepMaxMS: 75, 86 | }) 87 | go s.RunSimulation(ctx) 88 | go local.Run(ctx) 89 | 90 | if !inline { 91 | fmt.Printf("\n") 92 | } 93 | count := 0 94 | var ticker *time.Ticker 95 | if inline { 96 | ticker = time.NewTicker(time.Second * 2) 97 | } else { 98 | ticker = time.NewTicker(time.Millisecond * 500) 99 | } 100 | 101 | for !s.Done { 102 | <-ticker.C 103 | count++ 104 | if !inline { 105 | fmt.Printf("\n") 106 | } 107 | fmt.Printf("%s\n", s.String()) 108 | fmt.Printf("%s\n", mm.String()) 109 | } 110 | 111 | cancel() 112 | } 113 | -------------------------------------------------------------------------------- /cmd/sim/simple/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | // i am sure there is a better way to do this 7 | "path" 8 | "time" 9 | 10 | "vim-arcade.theprimeagen.com/e2e-tests/sim" 11 | "vim-arcade.theprimeagen.com/pkg/assert" 12 | gameserverstats "vim-arcade.theprimeagen.com/pkg/game-server-stats" 13 | servermanagement "vim-arcade.theprimeagen.com/pkg/server-management" 14 | ) 15 | 16 | 17 | func main() { 18 | logger := sim.CreateLogger("simple-sim") 19 | 20 | ctx, cancel := context.WithCancel(context.Background()) 21 | sim.KillContext(cancel) 22 | 23 | cwd, err := os.Getwd() 24 | assert.NoError(err, "unable to get cwd") 25 | p := path.Join(cwd, "e2e-tests/data/no_server") 26 | 27 | state := sim.CreateEnvironment(ctx, p, servermanagement.ServerParams{ 28 | MaxLoad: 0.9, 29 | }) 30 | 31 | logger.Info("Created environment", "state", state.String()) 32 | client := state.Factory.New() 33 | logger.Info("Created Client", "state", state.String()) 34 | 35 | defer cancel() 36 | sim.AssertClient(&state, client); 37 | sim.AssertConnectionCount(&state, gameserverstats.GameServecConfigConnectionStats{ 38 | Connections: 1, 39 | ConnectionsAdded: 1, 40 | ConnectionsRemoved: 0, 41 | }, time.Second * 5) 42 | 43 | client.Disconnect() 44 | 45 | stats := gameserverstats.GameServerConfig{ 46 | Id: client.ServerId, 47 | Connections: 0, 48 | ConnectionsAdded: 1, 49 | ConnectionsRemoved: 1, 50 | } 51 | 52 | // ok i want to assert things about match making now... 53 | // the proxy itself 54 | sim.AssertServerStats(&state, stats, time.Second * 5) 55 | } 56 | 57 | -------------------------------------------------------------------------------- /cmd/test/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | var id = 0 11 | func getNextId() int { 12 | id++ 13 | return id 14 | } 15 | 16 | type Suicedek struct { 17 | Id int 18 | Ctx context.Context 19 | Cancel context.CancelFunc 20 | } 21 | 22 | func NewSuicedek(ctx context.Context, cancel context.CancelFunc) *Suicedek { 23 | return &Suicedek{Id: getNextId(), Ctx: ctx, Cancel: cancel} 24 | } 25 | 26 | func (s *Suicedek) Run(wait *sync.WaitGroup) { 27 | <-s.Ctx.Done() 28 | fmt.Printf("S is done %d\n", s.Id) 29 | 30 | wait.Done() 31 | } 32 | 33 | func main() { 34 | parent, cancel := context.WithCancel(context.Background()) 35 | 36 | s := []*Suicedek{} 37 | for range 5 { 38 | c, can := context.WithCancel(parent) 39 | s = append(s, NewSuicedek(c, can)) 40 | } 41 | 42 | wait := sync.WaitGroup{} 43 | parentSu := s[0] 44 | for range 5 { 45 | c, can := context.WithCancel(parentSu.Ctx) 46 | s = append(s, NewSuicedek(c, can)) 47 | } 48 | 49 | wait.Add(len(s)) 50 | for _, su := range s { 51 | go su.Run(&wait) 52 | } 53 | 54 | s[0].Cancel() 55 | 56 | time.Sleep(time.Second * 10) 57 | 58 | cancel() 59 | 60 | wait.Wait() 61 | } 62 | 63 | -------------------------------------------------------------------------------- /e2e-tests/data/no_server: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePrimeagen/vim-arcade/ef7673313eab87d6a4533d3acb953360f8fcf291/e2e-tests/data/no_server -------------------------------------------------------------------------------- /e2e-tests/data/no_server-shm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePrimeagen/vim-arcade/ef7673313eab87d6a4533d3acb953360f8fcf291/e2e-tests/data/no_server-shm -------------------------------------------------------------------------------- /e2e-tests/data/no_server-wal: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePrimeagen/vim-arcade/ef7673313eab87d6a4533d3acb953360f8fcf291/e2e-tests/data/no_server-wal -------------------------------------------------------------------------------- /e2e-tests/matchmaking_test.go: -------------------------------------------------------------------------------- 1 | package e2etests 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "vim-arcade.theprimeagen.com/e2e-tests/sim" 9 | gameserverstats "vim-arcade.theprimeagen.com/pkg/game-server-stats" 10 | servermanagement "vim-arcade.theprimeagen.com/pkg/server-management" 11 | ) 12 | 13 | func TestMatchMakingCreateServer(t *testing.T) { 14 | logger := sim.CreateLogger("TestMatchMakingCreateServer") 15 | ctx, cancel := context.WithCancel(context.Background()) 16 | sim.KillContext(cancel) 17 | 18 | path := sim.GetDBPath("no_server") 19 | state := sim.CreateEnvironment(ctx, path, servermanagement.ServerParams{ 20 | MaxLoad: 0.9, 21 | }) 22 | 23 | logger.Info("Created environment", "state", state.String()) 24 | client := state.Factory.New() 25 | logger.Info("Created Client", "state", state.String()) 26 | 27 | t.Cleanup(func() {cancel()}) 28 | 29 | sim.AssertClient(&state, client); 30 | sim.AssertConnectionCount(&state, gameserverstats.GameServecConfigConnectionStats{ 31 | Connections: 1, 32 | ConnectionsAdded: 1, 33 | ConnectionsRemoved: 0, 34 | }, time.Second * 5) 35 | } 36 | 37 | func TestMakingServerWithBatchRequest(t *testing.T) { 38 | sim.CreateLogger("TestMakingServerWithBatchRequest") 39 | ctx, cancel := context.WithCancel(context.Background()) 40 | sim.KillContext(cancel) 41 | 42 | path := sim.GetDBPath("no_server") 43 | state := sim.CreateEnvironment(ctx, path, servermanagement.ServerParams{ 44 | MaxLoad: 0.9, 45 | }) 46 | 47 | clients := state.Factory.CreateBatchedConnections(15) 48 | t.Cleanup(func() {cancel()}) 49 | 50 | sim.AssertClients(&state, clients); 51 | sim.AssertAllClientsSameServer(&state, clients); 52 | sim.AssertConnectionCount(&state, gameserverstats.GameServecConfigConnectionStats{ 53 | Connections: 15, 54 | ConnectionsAdded: 15, 55 | ConnectionsRemoved: 0, 56 | }, time.Second * 5) 57 | } 58 | -------------------------------------------------------------------------------- /e2e-tests/run/configs/no_server: -------------------------------------------------------------------------------- 1 | { 2 | "servers": [] 3 | } 4 | 5 | -------------------------------------------------------------------------------- /e2e-tests/run/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "time" 9 | 10 | "vim-arcade.theprimeagen.com/pkg/assert" 11 | gameserverstats "vim-arcade.theprimeagen.com/pkg/game-server-stats" 12 | ) 13 | 14 | type E2EConfig struct { 15 | servers []gameserverstats.GameServerConfig 16 | } 17 | 18 | func main() { 19 | nameStr := "" 20 | flag.StringVar(&nameStr, "name", "", "the name of the data file") 21 | flag.Parse() 22 | 23 | assert.Assert(nameStr != "", "expected --name to be provided") 24 | name := fmt.Sprintf("e2e-tests/data/%s", nameStr) 25 | configPath := fmt.Sprintf("e2e-tests/run/configs/%s", nameStr) 26 | 27 | configBytes, err := os.ReadFile(configPath) 28 | assert.NoError(err, "unable to read config file") 29 | 30 | var config E2EConfig 31 | err = json.Unmarshal(configBytes, &config) 32 | assert.NoError(err, "unable to unmarshal config") 33 | 34 | sqlite := gameserverstats.NewSqlite("file:" + name) 35 | sqlite.SetSqliteModes() 36 | err = sqlite.CreateGameServerConfigs() 37 | assert.NoError(err, "unable to create game server config") 38 | 39 | for _, c := range config.servers { 40 | fmt.Printf("inserting: %+v\n", c) 41 | assert.NoError(sqlite.Update(c), "unable to update sqlite with config", "config", c) 42 | } 43 | 44 | time.Sleep(time.Millisecond * 500) 45 | assert.NoError(sqlite.Close(), "unable to close sqlite") 46 | fmt.Printf("sqlite configuration finished: %s\n", nameStr) 47 | } 48 | 49 | -------------------------------------------------------------------------------- /e2e-tests/sim/assert.go: -------------------------------------------------------------------------------- 1 | package sim 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "maps" 7 | "os" 8 | "slices" 9 | "strings" 10 | "time" 11 | 12 | "vim-arcade.theprimeagen.com/pkg/api" 13 | assert "vim-arcade.theprimeagen.com/pkg/assert" 14 | gameserverstats "vim-arcade.theprimeagen.com/pkg/game-server-stats" 15 | ) 16 | 17 | func AssertClients(state *ServerState, clients []*api.Client) { 18 | for _, client := range clients { 19 | AssertClient(state, client) 20 | } 21 | } 22 | 23 | func AssertAllClientsSameServer(state *ServerState, clients []*api.Client) { 24 | slog.Info("AssertAllClientsSameServer", "client", len(clients)) 25 | if len(clients) == 0 { 26 | return 27 | } 28 | 29 | ip := clients[0].Addr() 30 | for _, c := range clients { 31 | assert.Assert(c.Addr() == ip, "client ip isn't the same", "expected", ip, "received", c.Addr()) 32 | } 33 | } 34 | 35 | func AssertClient(state *ServerState, client *api.Client) { 36 | slog.Info("assertClient", "client", client.String()) 37 | config := state.Sqlite.GetById(client.ServerId) 38 | assert.NotNil(config, "expected a config to be present", "client", client) 39 | } 40 | 41 | func AssertConnectionsOnProxy(state *ServerState, count int) { 42 | slog.Info("AssertConnectionsOnProxy", "count", count) 43 | } 44 | 45 | func AssertServerStats(state *ServerState, stats gameserverstats.GameServerConfig, dur time.Duration) { 46 | slog.Info("AssertServerStats", "stats", stats.String()) 47 | 48 | start := time.Now() 49 | for time.Now().Sub(start) < dur { 50 | serverStats := state.Sqlite.GetById(stats.Id) 51 | if serverStats.Equal(&stats) { 52 | break 53 | } 54 | } 55 | 56 | serverStats := state.Sqlite.GetById(stats.Id) 57 | assert.Assert(serverStats.Equal(&stats), "expected the stats to be equal with the server stats", "expected", stats.String(), "received", serverStats.String()) 58 | } 59 | 60 | func AssertConnectionCount(state *ServerState, counts gameserverstats.GameServecConfigConnectionStats, dur time.Duration) { 61 | slog.Info("assertConnectionCount", "count", counts.String()) 62 | 63 | start := time.Now() 64 | for time.Now().Sub(start) < dur { 65 | conns := state.Sqlite.GetTotalConnectionCount() 66 | if conns.Equal(&counts) { 67 | break 68 | } 69 | } 70 | 71 | conns := state.Sqlite.GetTotalConnectionCount() 72 | assert.Assert(conns.Connections == counts.Connections, "expceted the same number of connections") 73 | assert.Assert(conns.ConnectionsAdded == counts.ConnectionsAdded, "expceted the same number of connections added") 74 | assert.Assert(conns.ConnectionsRemoved == counts.ConnectionsRemoved, "expceted the same number of connections removed") 75 | } 76 | 77 | func AssertServerStateCreation(server *ServerState, configs []ServerCreationConfig) { 78 | } 79 | 80 | type ConnectionValidator map[string]int 81 | 82 | func sumConfigConns(configs []gameserverstats.GameServerConfig) ConnectionValidator { 83 | out := make(map[string]int) 84 | for _, c := range configs { 85 | out[c.Addr()] = c.Connections 86 | } 87 | return out 88 | } 89 | 90 | func (c *ConnectionValidator) Add(conns []*api.Client) { 91 | for _, conn := range conns { 92 | fmt.Fprintf(os.Stderr, "ConnectionValidator#Add: %s\n", conn.Addr()) 93 | (*c)[conn.Addr()] += 1 94 | } 95 | } 96 | 97 | func (c *ConnectionValidator) Remove(conns []*api.Client) { 98 | for _, conn := range conns { 99 | fmt.Fprintf(os.Stderr, "ConnectionValidator#Remove: %s\n", conn.Addr()) 100 | (*c)[conn.Addr()] -= 1 101 | } 102 | } 103 | 104 | func (c *ConnectionValidator) String() string { 105 | out := make([]string, 0, len(*c)) 106 | for k, v := range *c { 107 | out = append(out, fmt.Sprintf("%s = %d", k, v)) 108 | } 109 | return strings.Join(out, "\n") 110 | } 111 | 112 | func AssertServerState(before []gameserverstats.GameServerConfig, after []gameserverstats.GameServerConfig, adds []*api.Client, removes []*api.Client) { 113 | beforeValidator := sumConfigConns(before) 114 | afterValidator := sumConfigConns(after) 115 | 116 | beforeValidator.Add(adds) 117 | beforeValidator.Remove(removes) 118 | 119 | beforeKeysIter := maps.Keys(beforeValidator) 120 | afterKeysIter := maps.Keys(afterValidator) 121 | 122 | beforeKeys := slices.SortedFunc(beforeKeysIter, func(a, b string) int { 123 | return strings.Compare(a, b) 124 | }) 125 | afterKeys := slices.SortedFunc(afterKeysIter, func(a, b string) int { 126 | return strings.Compare(a, b) 127 | }) 128 | 129 | assert.Assert(len(beforeKeys) == len(afterKeys), "before and after keys have different lengths", "before", beforeKeys, "after", afterKeys) 130 | for i, v := range beforeKeys { 131 | assert.Assert(afterKeys[i] == v, "before and after key order doesn't match", "i", i, "before", v, "after", afterKeys[i], "beforeKeys", beforeKeys, "afterKeys", afterKeys) 132 | if beforeValidator[v] != afterValidator[v] { 133 | slog.Error("--------------- Validation Failed ---------------") 134 | 135 | b := sumConfigConns(before) 136 | slog.Error("server state before", "before", b.String(), "after", afterValidator.String()) 137 | slog.Error("Adds", "count", len(adds)) 138 | for i, c := range adds { 139 | slog.Error(" client", "i", i, "addr", c.Addr()) 140 | } 141 | 142 | slog.Error("Removes", "count", len(removes)) 143 | for i, c := range removes { 144 | slog.Error(" client", "i", i, "addr", c.Addr()) 145 | } 146 | 147 | assert.Never("after vs before state + connections count mismatch", "failedOn", v, "currentState", afterValidator, "beforeState + connections added / removed", beforeValidator) 148 | } 149 | } 150 | } 151 | 152 | -------------------------------------------------------------------------------- /e2e-tests/sim/connections.go: -------------------------------------------------------------------------------- 1 | package sim 2 | 3 | import ( 4 | "context" 5 | "encoding/binary" 6 | "log/slog" 7 | "math/rand" 8 | "sync" 9 | 10 | "vim-arcade.theprimeagen.com/pkg/api" 11 | "vim-arcade.theprimeagen.com/pkg/assert" 12 | ) 13 | 14 | var clientId uint64 = 0 15 | func getNextId() [16]byte { 16 | id := [16]byte{} 17 | binary.BigEndian.PutUint64(id[:], clientId) 18 | clientId++ 19 | 20 | return id 21 | } 22 | 23 | type SimulationConnections struct { 24 | clients []*api.Client 25 | adds []*api.Client 26 | removes []*api.Client 27 | factory TestingClientFactory 28 | m sync.Mutex 29 | rand *rand.Rand 30 | logger *slog.Logger 31 | wait sync.WaitGroup 32 | } 33 | 34 | func NewSimulationConnections(f TestingClientFactory, r *rand.Rand) SimulationConnections { 35 | return SimulationConnections{ 36 | m: sync.Mutex{}, 37 | clients: []*api.Client{}, 38 | adds: []*api.Client{}, 39 | removes: []*api.Client{}, 40 | factory: f, 41 | rand: r, 42 | logger: slog.Default().With("area", "SimulationConnections"), 43 | } 44 | } 45 | 46 | func (s *SimulationConnections) Len() int { 47 | s.m.Lock() 48 | defer s.m.Unlock() 49 | return len(s.clients) 50 | } 51 | 52 | func (s *SimulationConnections) StartRound(adds int, removes int) { 53 | s.wait = sync.WaitGroup{} 54 | 55 | s.wait.Add(adds) 56 | s.wait.Add(removes) 57 | } 58 | 59 | func (s *SimulationConnections) AssertAddsAndRemoves() { 60 | for _, c := range s.adds { 61 | assert.Assert(c.State == api.CSConnected, "state of connection is not connected", "state", api.ClientStateToString(c.State)) 62 | } 63 | 64 | for _, c := range s.removes { 65 | assert.Assert(c.State == api.CSDisconnected, "state of connection is not disconnected", "state", api.ClientStateToString(c.State)) 66 | } 67 | 68 | } 69 | 70 | func (s *SimulationConnections) FinishRound() ([]*api.Client, []*api.Client) { 71 | s.wait.Wait() 72 | 73 | removes := s.removes 74 | adds := s.adds 75 | 76 | s.removes = []*api.Client{} 77 | s.adds = []*api.Client{} 78 | 79 | return adds, removes 80 | } 81 | 82 | func (s *SimulationConnections) AddBatch(count int) int { 83 | clients := s.factory.CreateBatchedConnectionsWithWait(count, &s.wait) 84 | 85 | s.m.Lock() 86 | defer s.m.Unlock() 87 | 88 | idx := len(s.clients) 89 | s.clients = append(s.clients, clients...) 90 | s.adds = append(s.adds, clients...) 91 | return idx 92 | } 93 | 94 | func (s *SimulationConnections) Add() int { 95 | client := s.factory.NewWait(&s.wait) 96 | 97 | s.m.Lock() 98 | defer s.m.Unlock() 99 | 100 | idx := len(s.clients) 101 | s.clients = append(s.clients, client) 102 | s.adds = append(s.adds, client) 103 | s.logger.Info("Add", "len", len(s.adds)) 104 | return idx 105 | } 106 | 107 | func (s *SimulationConnections) Remove(count int) { 108 | removal := func(count int) []*api.Client { 109 | out := make([]*api.Client, 0, count) 110 | s.m.Lock() 111 | defer s.m.Unlock() 112 | 113 | length := len(s.clients) - len(s.adds) 114 | for range count { 115 | idx := s.rand.Int() % length 116 | out = append(out, s.clients[idx]) 117 | s.clients = append(s.clients[0:idx], s.clients[idx+1:]...) 118 | length-- 119 | } 120 | 121 | return out 122 | } 123 | 124 | removes := removal(count) 125 | s.removes = append(s.removes, removes...) 126 | 127 | assert.Assert(len(removes) == count, "we did not remove enough connections", "removes", len(removes), "count", count) 128 | for _, c := range removes { 129 | c.Disconnect() 130 | s.wait.Done() 131 | s.logger.Warn("Disconnect Client", "addr", c.Addr()) 132 | } 133 | } 134 | 135 | type TestingClientFactory struct { 136 | host string 137 | port uint16 138 | logger *slog.Logger 139 | } 140 | 141 | func NewTestingClientFactory(host string, port uint16, logger *slog.Logger) TestingClientFactory { 142 | return TestingClientFactory{ 143 | logger: logger.With("area", "TestClientFactory"), 144 | host: host, 145 | port: port, 146 | } 147 | } 148 | 149 | func (f *TestingClientFactory) CreateBatchedConnectionsWithWait(count int, wait *sync.WaitGroup) []*api.Client { 150 | conns := make([]*api.Client, 0) 151 | 152 | f.logger.Info("creating all clients", "count", count) 153 | for range count { 154 | conns = append(conns, f.NewWait(wait)) 155 | } 156 | f.logger.Info("clients all created", "count", count) 157 | 158 | return conns 159 | } 160 | 161 | func (f *TestingClientFactory) CreateBatchedConnections(count int) []*api.Client { 162 | wait := &sync.WaitGroup{} 163 | clients := f.CreateBatchedConnectionsWithWait(count, wait) 164 | 165 | f.logger.Info("CreateBatchedConnections waiting", "count", count) 166 | wait.Wait() 167 | 168 | return clients 169 | } 170 | 171 | func (f TestingClientFactory) WithPort(port uint16) TestingClientFactory { 172 | f.port = port 173 | return f 174 | } 175 | 176 | func (f *TestingClientFactory) New() *api.Client { 177 | client := api.NewClient(f.host, f.port, getNextId()) 178 | f.logger.Info("factory connecting", "id", client.Id()) 179 | client.Connect(context.Background()) 180 | client.WaitForReady() 181 | f.logger.Info("factory connected", "id", client.Id()) 182 | return &client 183 | } 184 | 185 | // this is getting hacky... 186 | func (f *TestingClientFactory) NewWait(wait *sync.WaitGroup) *api.Client { 187 | client := api.NewClient(f.host, f.port, [16]byte(getNextId())) 188 | 189 | id := client.Id() 190 | f.logger.Info("factory new client with wait", "id", id) 191 | 192 | go func() { 193 | defer func() { 194 | f.logger.Info("factory client connected with wait", "id", id) 195 | wait.Done() 196 | }() 197 | 198 | f.logger.Info("factory client connecting with wait", "id", id) 199 | err := client.Connect(context.Background()) 200 | assert.NoError(err, "unable to connect to mm", "id", id) 201 | client.WaitForReady() 202 | }() 203 | 204 | return &client 205 | } 206 | -------------------------------------------------------------------------------- /e2e-tests/sim/create_env.go: -------------------------------------------------------------------------------- 1 | package sim 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log/slog" 7 | "os" 8 | "path" 9 | 10 | amproxy "vim-arcade.theprimeagen.com/pkg/am-proxy" 11 | "vim-arcade.theprimeagen.com/pkg/api" 12 | "vim-arcade.theprimeagen.com/pkg/assert" 13 | gameserverstats "vim-arcade.theprimeagen.com/pkg/game-server-stats" 14 | servermanagement "vim-arcade.theprimeagen.com/pkg/server-management" 15 | ) 16 | 17 | type ServerCreationConfig struct { 18 | From gameserverstats.GameServerConfig 19 | To gameserverstats.GameServerConfig 20 | } 21 | 22 | func createServer(ctx context.Context, server *ServerState, logger *slog.Logger) (string, *gameserverstats.GameServerConfig) { 23 | logger.Info("creating server") 24 | sId, err := server.Server.CreateNewServer(ctx) 25 | logger.Info("created server", "id", sId, "err", err) 26 | assert.NoError(err, "unable to create server") 27 | logger.Info("waiting server...", "id", sId) 28 | server.Server.WaitForReady(ctx, sId) 29 | logger.Info("server ready", "id", sId) 30 | sConfig := server.Sqlite.GetById(sId) 31 | logger.Info("server config", "config", sConfig) 32 | assert.NotNil(sConfig, "unable to get config by id", "id", sId) 33 | return sId, sConfig 34 | } 35 | 36 | type ConnMap map[string][]*api.Client 37 | 38 | func hydrateServers(ctx context.Context, server *ServerState, logger *slog.Logger) (ConnMap, []ServerCreationConfig) { 39 | configs, err := server.Sqlite.GetAllGameServerConfigs() 40 | assert.NoError(err, "unable to get game server configs") 41 | clearCreationConfigs(server, configs) 42 | 43 | connMap := make(ConnMap) 44 | configMapper := []ServerCreationConfig{} 45 | logger.Info("Hydrating Servers", "count", len(configs)) 46 | for _, c := range configs { 47 | 48 | logger.Info("Creating server with the following config", "config", c) 49 | 50 | sId, sConfig := createServer(ctx, server, logger) 51 | factory := server.Factory.WithPort(uint16(sConfig.Port)) 52 | conns := factory.CreateBatchedConnections(c.Connections) 53 | 54 | connMap[sId] = conns 55 | configMapper = append(configMapper, ServerCreationConfig{ 56 | From: c, 57 | To: *sConfig, 58 | }) 59 | } 60 | 61 | return connMap, configMapper 62 | } 63 | 64 | func copyFile(from string, to string) { 65 | toFd, err := os.OpenFile(to, os.O_RDWR|os.O_CREATE, 0644) 66 | assert.NoError(err, "unable to open toFile") 67 | defer toFd.Close() 68 | 69 | fromFd, err := os.Open(from) 70 | assert.NoError(err, "unable to open toFile") 71 | defer fromFd.Close() 72 | 73 | _, err = io.Copy(toFd, fromFd) 74 | assert.NoError(err, "unable to copy file") 75 | } 76 | 77 | func copyDBFile(path string) string { 78 | 79 | f, err := os.CreateTemp("/tmp", "mm-testing-") 80 | assert.NoError(err, "unable to create tmp") 81 | fName := f.Name() 82 | f.Close() 83 | 84 | copyFile(path, fName) 85 | copyFile(path + "-shm", fName + "-shm") 86 | copyFile(path + "-wal", fName + "-wal") 87 | 88 | return fName 89 | } 90 | 91 | func GetDBPath(name string) string { 92 | cwd, err := os.Getwd() 93 | assert.NoError(err, "no cwd?") 94 | 95 | // assert: windows sucks 96 | return path.Join(cwd, "data", name) 97 | } 98 | 99 | func clearCreationConfigs(server *ServerState, configs []gameserverstats.GameServerConfig) { 100 | for _, c := range configs { 101 | server.Sqlite.DeleteGameServerConfig(c.Id) 102 | } 103 | } 104 | 105 | func CreateEnvironment(ctx context.Context, path string, params servermanagement.ServerParams) ServerState { 106 | logger := slog.Default().With("area", "create-env") 107 | logger.Warn("copying db file", "path", path) 108 | path = copyDBFile(path) 109 | os.Setenv("SQLITE", path) 110 | os.Setenv("ENV", "TESTING") 111 | 112 | port, err := api.GetFreePort() 113 | assert.NoError(err, "unable to get a free port") 114 | 115 | logger.Info("creating sqlite", "path", path) 116 | sqlite := gameserverstats.NewSqlite(gameserverstats.EnsureSqliteURI(path)) 117 | logger.Info("creating local servers", "params", params) 118 | local := servermanagement.NewLocalServers(sqlite, params) 119 | logger.Info("creating matchmaking", "port", port) 120 | 121 | proxy := amproxy.NewAMProxy(ctx, &local, amproxy.CreateTCPConnectionFrom) 122 | tcpProxy := amproxy.NewTCPProxy(&proxy, uint16(port)) 123 | go tcpProxy.Run(ctx) 124 | tcpProxy.WaitForReady(ctx) 125 | 126 | logger.Info("creating client factory", "port", port) 127 | factory := NewTestingClientFactory("0.0.0.0", uint16(port), logger) 128 | 129 | logger.Info("creating server state object", "port", port) 130 | server := ServerState{ 131 | Sqlite: sqlite, 132 | Server: &local, 133 | Proxy: &tcpProxy, 134 | Port: port, 135 | Factory: &factory, 136 | Conns: nil, 137 | } 138 | 139 | logger.Info("hydrating servers", "port", port) 140 | conns, configs := hydrateServers(ctx, &server, logger) 141 | server.Conns = conns 142 | 143 | AssertServerStateCreation(&server, configs) 144 | 145 | logger.Info("environment fully created") 146 | return server 147 | } 148 | 149 | -------------------------------------------------------------------------------- /e2e-tests/sim/sim.go: -------------------------------------------------------------------------------- 1 | package sim 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "math" 8 | "math/rand" 9 | "sync" 10 | "time" 11 | 12 | "vim-arcade.theprimeagen.com/pkg/assert" 13 | gameserverstats "vim-arcade.theprimeagen.com/pkg/game-server-stats" 14 | ) 15 | 16 | type SimulationParams struct { 17 | Seed int64 18 | Rounds int 19 | Host string 20 | Port uint16 21 | Stats gameserverstats.GSSRetriever 22 | StdConnections int 23 | MaxBatchConnectionChange int 24 | TimeToConnectionCountMS int64 25 | ConnectionSleepMinMS int 26 | ConnectionSleepMaxMS int 27 | } 28 | 29 | type Simulation struct { 30 | params SimulationParams 31 | rand *rand.Rand 32 | Done bool 33 | logger *slog.Logger 34 | mutex sync.Mutex 35 | adds int 36 | removes int 37 | totalAdds int 38 | totalRemoves int 39 | currentRound int 40 | } 41 | 42 | func NewSimulation(params SimulationParams) Simulation { 43 | return Simulation{ 44 | logger: slog.Default().With("area", "Simulation"), 45 | params: params, 46 | rand: rand.New(rand.NewSource(params.Seed)), 47 | mutex: sync.Mutex{}, 48 | totalAdds: 0, 49 | totalRemoves: 0, 50 | } 51 | } 52 | 53 | func getNextBatch(s *Simulation, remaining int) int { 54 | maxRemaining := min(remaining, s.params.MaxBatchConnectionChange) 55 | randomRemaining := s.nextInt(1, maxRemaining) 56 | return randomRemaining 57 | } 58 | 59 | func (s *Simulation) nextInt(min int, max int) int { 60 | out := s.rand.Int() 61 | diff := max - min 62 | if diff == 0 { 63 | return min 64 | } 65 | 66 | return min + out%diff 67 | } 68 | 69 | func (s *Simulation) String() string { 70 | return fmt.Sprintf(`----- Simulation ----- 71 | adds: %d (%d) 72 | removes: %d (%d) 73 | round: %d 74 | `, s.adds, s.totalAdds, s.removes, s.totalRemoves, s.currentRound) 75 | } 76 | 77 | func (s *Simulation) RunSimulation(ctx context.Context) error { 78 | s.Done = false 79 | 80 | factory := NewTestingClientFactory(s.params.Host, s.params.Port, s.logger) 81 | connections := NewSimulationConnections(factory, s.rand) 82 | waiter := NewStateWaiter(s.params.Stats) 83 | waitTime := time.Millisecond * time.Duration(s.params.TimeToConnectionCountMS) 84 | 85 | s.logger.Error("starting simulation", "waitTime", waitTime/time.Millisecond) 86 | // Seed the random number generator for different results each time 87 | outer: 88 | for round := range s.params.Rounds { 89 | s.currentRound = round 90 | 91 | select { 92 | case <-ctx.Done(): 93 | break outer 94 | default: 95 | } 96 | 97 | adds := int(math.Abs(s.rand.NormFloat64() * float64(s.params.StdConnections))) 98 | removes := min(connections.Len(), int(math.Abs(s.rand.NormFloat64()*float64(s.params.StdConnections)))) 99 | 100 | startingConns := waiter.StartRound() 101 | connections.StartRound(adds, removes) 102 | 103 | expectedDone := startingConns 104 | expectedDone.Connections += adds - removes 105 | expectedDone.ConnectionsAdded += adds 106 | expectedDone.ConnectionsRemoved += removes 107 | 108 | s.logger.Info("SimRound", "round", round, "adds", adds, "removes", removes, "current", startingConns, "expected", expectedDone) 109 | 110 | go func() { 111 | s.adds = adds 112 | for s.adds > 0 { 113 | randomAdds := getNextBatch(s, s.adds) 114 | s.adds -= randomAdds 115 | 116 | assert.Assert(s.adds >= 0, "s.adds somehow become negative") 117 | 118 | <-time.NewTimer(time.Millisecond * time.Duration(s.nextInt(s.params.ConnectionSleepMinMS, s.params.ConnectionSleepMaxMS))).C 119 | _ = connections.AddBatch(randomAdds) 120 | } 121 | }() 122 | 123 | go func() { 124 | s.removes = removes 125 | for s.removes > 0 { 126 | randomRemoves := getNextBatch(s, s.removes) 127 | s.removes -= randomRemoves 128 | 129 | if connections.Len() == 0 { 130 | continue 131 | } 132 | 133 | <-time.NewTimer(time.Millisecond * time.Duration(s.nextInt(s.params.ConnectionSleepMinMS, s.params.ConnectionSleepMaxMS))).C 134 | connections.Remove(randomRemoves) 135 | } 136 | }() 137 | 138 | addedConns, removedConns := connections.FinishRound() 139 | waiter.WaitForRound(adds, removes, time.Duration(waitTime)) 140 | 141 | s.logger.Error("Added and Removed Conns", "expectedAdds", adds, "expectedRemoves", removes, "addedConns", len(addedConns), "removedConns", len(removedConns)) 142 | s.totalAdds += adds 143 | s.totalRemoves += removes 144 | 145 | timeTaken := waiter.AssertRound(addedConns, removedConns) 146 | s.logger.Info("SimRound finished", "round", round, "totalAdds", s.totalAdds, "totalRemoves", s.totalRemoves, "time taken ms", timeTaken.Milliseconds()) 147 | } 148 | 149 | s.logger.Warn("Simulation Completed") 150 | s.Done = true 151 | return nil 152 | } 153 | -------------------------------------------------------------------------------- /e2e-tests/sim/state.go: -------------------------------------------------------------------------------- 1 | package sim 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "strings" 7 | "time" 8 | 9 | amproxy "vim-arcade.theprimeagen.com/pkg/am-proxy" 10 | "vim-arcade.theprimeagen.com/pkg/api" 11 | assert "vim-arcade.theprimeagen.com/pkg/assert" 12 | gameserverstats "vim-arcade.theprimeagen.com/pkg/game-server-stats" 13 | servermanagement "vim-arcade.theprimeagen.com/pkg/server-management" 14 | ) 15 | 16 | type ServerState struct { 17 | Sqlite *gameserverstats.Sqlite 18 | Server *servermanagement.LocalServers 19 | Proxy *amproxy.AMTCPProxy 20 | Port int 21 | Factory *TestingClientFactory 22 | Conns ConnMap 23 | } 24 | 25 | func (s *ServerState) Close() { 26 | s.Proxy.Close() 27 | s.Server.Close() 28 | 29 | err := s.Sqlite.Close() 30 | assert.NoError(err, "sqlite errored on close") 31 | } 32 | 33 | func (s *ServerState) String() string { 34 | configs, err := s.Sqlite.GetAllGameServerConfigs() 35 | configsStr := strings.Builder{} 36 | if err != nil { 37 | _, err = configsStr.WriteString(fmt.Sprintf("unable to get server configs: %s", err)) 38 | assert.NoError(err, "never should happen (famous last words)") 39 | } else { 40 | for i, c := range configs { 41 | if i > 0 { 42 | configsStr.WriteString("\n") 43 | } 44 | configsStr.WriteString(c.String()) 45 | } 46 | } 47 | 48 | connections := s.Sqlite.GetTotalConnectionCount() 49 | return fmt.Sprintf(`ServerState: 50 | Connections: %s 51 | Servers 52 | %s 53 | `, connections.String(), configsStr.String()) 54 | } 55 | 56 | type ServerStateWaiter struct { 57 | Stats gameserverstats.GSSRetriever 58 | startConfigs []gameserverstats.GameServerConfig 59 | conns gameserverstats.GameServecConfigConnectionStats 60 | startTime time.Time 61 | logger *slog.Logger 62 | } 63 | 64 | func NewStateWaiter(stats gameserverstats.GSSRetriever) *ServerStateWaiter { 65 | return &ServerStateWaiter{ 66 | Stats: stats, 67 | startConfigs: []gameserverstats.GameServerConfig{}, 68 | logger: slog.Default().With("area", "StateWaiter"), 69 | } 70 | } 71 | 72 | func (s *ServerStateWaiter) StartRound() gameserverstats.GameServecConfigConnectionStats { 73 | startConfigs, err := s.Stats.GetAllGameServerConfigs() 74 | assert.NoError(err, "StartRound: unable to get all server configs") 75 | s.startConfigs = startConfigs 76 | s.conns = s.Stats.GetTotalConnectionCount() 77 | s.startTime = time.Now() 78 | 79 | return s.conns 80 | } 81 | 82 | func (s *ServerStateWaiter) WaitForRound(added, removed int, t time.Duration) { 83 | s.conns.Connections += added - removed 84 | s.conns.ConnectionsRemoved += removed 85 | s.conns.ConnectionsAdded += added 86 | 87 | start := time.Now() 88 | for time.Now().Sub(start).Milliseconds() < t.Milliseconds() { 89 | conns := s.Stats.GetTotalConnectionCount() 90 | 91 | if conns.Equal(&s.conns) { 92 | break 93 | } 94 | <-time.NewTimer(time.Millisecond * 250).C 95 | } 96 | } 97 | 98 | func (s *ServerStateWaiter) AssertRound(adds, removes []*api.Client) time.Duration { 99 | endConfig, err := s.Stats.GetAllGameServerConfigs() 100 | assert.NoError(err, "AssertRound: unable to get configs") 101 | AssertServerState(s.startConfigs, endConfig, adds, removes) 102 | 103 | return time.Now().Sub(s.startTime) 104 | } 105 | -------------------------------------------------------------------------------- /e2e-tests/sim/utils.go: -------------------------------------------------------------------------------- 1 | package sim 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "time" 7 | 8 | assert "vim-arcade.theprimeagen.com/pkg/assert" 9 | prettylog "vim-arcade.theprimeagen.com/pkg/pretty-log" 10 | ) 11 | 12 | func KillContext(cancel context.CancelFunc) { 13 | go func() { 14 | time.Sleep(time.Second * 5) 15 | cancel() 16 | assert.Never("context should never be killed with KillContext") 17 | }() 18 | } 19 | 20 | func CreateLogger(name string) *slog.Logger { 21 | logger := prettylog.CreateLoggerFromEnv(nil) 22 | logger = logger.With("area", name).With("process", "sim") 23 | slog.SetDefault(logger) 24 | 25 | logger.Error("Test Logger Created") 26 | 27 | return logger 28 | } 29 | 30 | 31 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for vim-arcade on 2024-09-03T18:49:05-06:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'vim-arcade' 7 | primary_region = 'den' 8 | 9 | [build] 10 | [build.args] 11 | GO_VERSION = '1.23.0' 12 | 13 | [env] 14 | PORT = '8080' 15 | 16 | [http_service] 17 | internal_port = 8080 18 | force_https = true 19 | auto_stop_machines = 'stop' 20 | auto_start_machines = true 21 | min_machines_running = 0 22 | processes = ['app'] 23 | 24 | [[vm]] 25 | memory = '1gb' 26 | cpu_kind = 'shared' 27 | cpus = 1 28 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module vim-arcade.theprimeagen.com 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/jmoiron/sqlx v1.4.0 7 | github.com/joho/godotenv v1.5.1 8 | github.com/stretchr/testify v1.9.0 9 | github.com/tursodatabase/go-libsql v0.0.0-20240916111504-922dfa87e1e6 10 | golang.org/x/sync v0.6.0 11 | ) 12 | 13 | require ( 14 | github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect 17 | github.com/pmezard/go-difflib v1.0.0 // indirect 18 | golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect 19 | gopkg.in/yaml.v3 v3.0.1 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 8 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 9 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 10 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 11 | github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= 12 | github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= 13 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 14 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 15 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 16 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 17 | github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM= 18 | github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM= 19 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 20 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 21 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 22 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 23 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 24 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 25 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 26 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 27 | github.com/tursodatabase/go-libsql v0.0.0-20240916111504-922dfa87e1e6 h1:bFxO2fsY5mHZRrVvhmrAo/O8Agi9HDAIMmmOClZMrkQ= 28 | github.com/tursodatabase/go-libsql v0.0.0-20240916111504-922dfa87e1e6/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8= 29 | golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU= 30 | golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 31 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 32 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 33 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 35 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 36 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 37 | gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= 38 | gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= 39 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | e2e-setup: clean 2 | go run ./e2e-tests/run/main.go --name no_server 3 | 4 | clean: 5 | rm -rf ./e2e-tests/data/* 6 | 7 | e2e-debug: 8 | DEBUG_LOG=/tmp/mm-testing GAME_SERVER="{{justfile_directory()}}/cmd/api-server/main.go" go test -v ./e2e-tests/... & 9 | tail -F /tmp/mm-testing & 10 | wait 11 | 12 | e2e: 13 | GAME_SERVER="{{justfile_directory()}}/cmd/api-server/main.go" go test ./e2e-tests/... 14 | 15 | kill-tests: 16 | ps aux | grep "go test" | grep -v "grep" | awk '{print $2}' | xargs -I {} kill -9 {} 17 | 18 | sim-search-id id: 19 | cat err | grep ":{{id}}" | go run ./cmd/log-parser/main.go 20 | -------------------------------------------------------------------------------- /main.just: -------------------------------------------------------------------------------- 1 | e2e: 2 | go run ./e2e-tests/run/main.go --name no_server 3 | -------------------------------------------------------------------------------- /pkg/am-proxy/am-proxy-config.go: -------------------------------------------------------------------------------- 1 | package amproxy 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | 7 | "vim-arcade.theprimeagen.com/pkg/assert" 8 | ) 9 | 10 | type AMProxyConfig struct { 11 | AuthTimeoutMS int64 `json:"authTimeoutMS"` 12 | } 13 | 14 | func readInt(key string, d int) int { 15 | vStr := os.Getenv(key) 16 | v, err := strconv.Atoi(vStr) 17 | assert.Assert(err == nil || err != nil && len(vStr) == 0, "environment provided an invalid int") 18 | 19 | if err != nil { 20 | return d 21 | } 22 | 23 | return v 24 | } 25 | 26 | func AMProxyConfigFromEnv() AMProxyConfig { 27 | return AMProxyConfig{ 28 | AuthTimeoutMS: int64(readInt("AUTH_TIMEOUT_MS", 5000)), 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /pkg/am-proxy/am-proxy.go: -------------------------------------------------------------------------------- 1 | package amproxy 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "vim-arcade.theprimeagen.com/pkg/assert" 9 | "vim-arcade.theprimeagen.com/pkg/packet" 10 | ) 11 | 12 | var AMProxyDisallowed = fmt.Errorf("unable to connnect, please try again later") 13 | 14 | type AMConnectionWrapper struct { 15 | cConn AMConnection 16 | gConn AMConnection 17 | 18 | ctx context.Context 19 | cancel context.CancelFunc 20 | 21 | cFramer packet.PacketFramer 22 | gFramer packet.PacketFramer 23 | 24 | // hell yeah brother 25 | gsId string 26 | } 27 | 28 | func (a *AMConnectionWrapper) Close() error { 29 | a.cancel() 30 | a.cConn.Close() 31 | if a.gConn != nil { 32 | a.gConn.Close() 33 | } 34 | return nil 35 | } 36 | 37 | type AMProxyStats struct { 38 | ActiveConnections int 39 | TotalConnections int 40 | Errors int 41 | } 42 | 43 | type AMProxy struct { 44 | servers GameServer 45 | match *MatchMakingServer 46 | factory ConnectionFactory 47 | 48 | logger *slog.Logger 49 | ctx context.Context 50 | cancel context.CancelFunc 51 | closed bool 52 | stats AMProxyStats 53 | } 54 | 55 | func NewAMProxy(outer context.Context, servers GameServer, factory ConnectionFactory) AMProxy { 56 | ctx, cancel := context.WithCancel(outer) 57 | return AMProxy{ 58 | servers: servers, 59 | match: NewMatchMakingServer(servers), 60 | factory: factory, 61 | 62 | logger: slog.Default().With("area", "AMProxy"), 63 | ctx: ctx, 64 | cancel: cancel, 65 | closed: false, 66 | stats: AMProxyStats{}, 67 | } 68 | } 69 | 70 | func (m *AMProxy) allowedToConnect(AMConnection) error { 71 | return nil 72 | } 73 | 74 | func (m *AMProxy) Add(conn AMConnection) error { 75 | assert.Assert(m.closed == false, "adding connections when the proxy has been closed") 76 | 77 | m.stats.ActiveConnections += 1 78 | 79 | if err := m.allowedToConnect(conn); err != nil { 80 | return err 81 | } 82 | 83 | ctx, cancel := context.WithCancel(m.ctx) 84 | wrapper := &AMConnectionWrapper{ 85 | cConn: conn, 86 | ctx: ctx, 87 | cancel: cancel, 88 | } 89 | 90 | go m.handleConnection(wrapper) 91 | 92 | return nil 93 | } 94 | 95 | func (m *AMProxy) authenticate(*packet.Packet) error { 96 | return nil 97 | } 98 | 99 | func (m *AMProxy) removeConnection(w *AMConnectionWrapper, report error) { 100 | 101 | if report != nil { 102 | pkt := packet.CreateErrorPacket(report) 103 | _, err := pkt.Into(w.cConn) 104 | if err != nil { 105 | m.logger.Error("could not write error message into connection", "error", err) 106 | } 107 | } 108 | 109 | w.Close() 110 | } 111 | 112 | func (m *AMProxy) handleConnection(w *AMConnectionWrapper) { 113 | w.cFramer = packet.NewPacketFramer() 114 | w.gFramer = packet.NewPacketFramer() 115 | go packet.FrameWithReader(&w.cFramer, w.cConn) 116 | 117 | // TODO(v1) this could hang forever and dumb brown hat hackers could hurt 118 | // my delicious ports and memory :( 119 | authPacket, ok := <-w.cFramer.C 120 | 121 | if !ok || authPacket == nil { 122 | m.removeConnection(w, nil) 123 | return 124 | } 125 | 126 | // TODO i probably want to have a "user" object that i can 127 | // serialize/deserialize 128 | if err := m.authenticate(authPacket); err != nil { 129 | m.removeConnection(w, err) 130 | return 131 | } 132 | 133 | // there is only one place to execute this... 134 | gameConnInfo, err := m.match.matchmake(m.ctx, w.cConn) 135 | if err != nil { 136 | m.removeConnection(w, err) 137 | return 138 | } 139 | 140 | gameConn, err := m.factory(gameConnInfo.Addr) 141 | if err != nil { 142 | m.removeConnection(w, err) 143 | return 144 | } 145 | 146 | w.gConn = gameConn 147 | go packet.FrameWithReader(&w.gFramer, w.gConn) 148 | 149 | // wait.. what is the id??? 150 | resp := packet.CreateServerAuthResponse(true, gameConnInfo.Id) 151 | _, err = resp.Into(w.cConn) 152 | if err != nil { 153 | m.removeConnection(w, err) 154 | return 155 | } 156 | 157 | go m.handleConnectionLifecycles(w) 158 | } 159 | 160 | func (m *AMProxy) handleConnectionLifecycles(w *AMConnectionWrapper) { 161 | for { 162 | select { 163 | case pkt := <-w.gFramer.C: 164 | switch pkt.Type() { 165 | case packet.PacketCloseConnection: 166 | _, err := pkt.Into(w.cConn) 167 | m.removeConnection(w, err) 168 | default: 169 | _, err := pkt.Into(w.cConn) 170 | if err != nil { 171 | m.removeConnection(w, err) 172 | } 173 | } 174 | case pkt := <-w.cFramer.C: 175 | switch pkt.Type() { 176 | case packet.PacketCloseConnection: 177 | _, err := pkt.Into(w.gConn) 178 | m.removeConnection(w, err) 179 | default: 180 | _, err := pkt.Into(w.gConn) 181 | if err != nil { 182 | m.removeConnection(w, err) 183 | } 184 | } 185 | case <-w.ctx.Done(): 186 | m.logger.Info("connection finished", "server-id", w.gsId) 187 | } 188 | } 189 | } 190 | 191 | func (m *AMProxy) Close() { 192 | m.logger.Warn("closing down") 193 | m.closed = true 194 | m.cancel() 195 | } 196 | 197 | func (p *AMProxy) Run() { 198 | <-p.ctx.Done() 199 | p.Close() 200 | } 201 | -------------------------------------------------------------------------------- /pkg/am-proxy/game.go: -------------------------------------------------------------------------------- 1 | package amproxy 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | // TODO consider all of these operations with game type 9 | // there will possibly be a day where i have more than one game type 10 | //go:generate mockery --name GameServer 11 | type GameServer interface { 12 | GetBestServer() (string, error) 13 | CreateNewServer(ctx context.Context) (string, error) 14 | WaitForReady(ctx context.Context, id string) error 15 | GetConnectionString(id string) (string, error) 16 | //ListServers() []gameserverstats.GameServerConfig 17 | String() string 18 | } 19 | 20 | type AMConnection interface { 21 | io.ReadWriteCloser 22 | Addr() string 23 | Id() string 24 | } 25 | 26 | type ConnectionFactory func(string) (AMConnection, error) 27 | -------------------------------------------------------------------------------- /pkg/am-proxy/matchmaking.go: -------------------------------------------------------------------------------- 1 | package amproxy 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "sync" 10 | 11 | "vim-arcade.theprimeagen.com/pkg/assert" 12 | servermanagement "vim-arcade.theprimeagen.com/pkg/server-management" 13 | ) 14 | 15 | type MatchMakingServer struct { 16 | servers GameServer 17 | logger *slog.Logger 18 | listener net.Listener 19 | ready bool 20 | 21 | waitingForServer bool 22 | 23 | mutex sync.Mutex 24 | wait sync.WaitGroup 25 | lastCreatedGameId string 26 | } 27 | 28 | func (m *MatchMakingServer) startWaiting() bool { 29 | m.mutex.Lock() 30 | defer m.mutex.Unlock() 31 | 32 | if !m.waitingForServer { 33 | m.waitingForServer = true 34 | m.wait = sync.WaitGroup{} 35 | m.wait.Add(1) 36 | return true 37 | } 38 | 39 | return false 40 | } 41 | 42 | func (m *MatchMakingServer) stopWaiting() { 43 | m.mutex.Lock() 44 | defer m.mutex.Unlock() 45 | 46 | if !m.waitingForServer { 47 | return 48 | } 49 | 50 | m.wait.Done() 51 | m.waitingForServer = false 52 | } 53 | 54 | func (m *MatchMakingServer) createAndWait(ctx context.Context) string { 55 | m.logger.Info("going to create and wait for new game server") 56 | if !m.startWaiting() { 57 | m.logger.Info("already waiting on server") 58 | m.wait.Wait() 59 | m.logger.Info("waited for server to be created", "id", m.lastCreatedGameId) 60 | return m.lastCreatedGameId 61 | } 62 | 63 | // TODO messaging goes way better... 64 | // TODO horizontal scaling can be quite difficult for the current method 65 | gameId, err := m.servers.CreateNewServer(ctx) 66 | 67 | // seee i hate this method.. it feels very prone to failure... 68 | m.lastCreatedGameId = gameId 69 | 70 | if err != nil { 71 | // TODO If there are no more servers available to create (max server count) 72 | // then the queue needs to begin 73 | assert.Never("unimplemented") 74 | 75 | // i am thinking that i am going to have to share the error across the 76 | // connections 77 | } 78 | 79 | m.logger.Info("waiting for server", "id", gameId) 80 | err = m.servers.WaitForReady(ctx, gameId) 81 | m.logger.Info("server created", "id", gameId) 82 | assert.NoError(err, "i need to be able to handle the issue of failing to create server or the server cannot ready") 83 | 84 | m.stopWaiting() 85 | return gameId 86 | } 87 | 88 | type GameConnectionInfo struct { 89 | Id string 90 | Addr string 91 | } 92 | 93 | // TODO(v1) create no garbage ([]byte...) 94 | func (m *MatchMakingServer) matchmake(ctx context.Context, conn AMConnection) (*GameConnectionInfo, error) { 95 | connId := conn.Id() 96 | gameId, err := m.servers.GetBestServer() 97 | 98 | m.logger.Info("getting best server", "gameId", gameId, "error", err, "id", connId) 99 | if errors.Is(err, servermanagement.NoBestServer) { 100 | gameId = m.createAndWait(ctx) 101 | } else if err != nil { 102 | m.logger.Error("getting best server error", "error", err, "id", connId) 103 | return nil, err 104 | } 105 | 106 | gs, err := m.servers.GetConnectionString(gameId) 107 | assert.NoError(err, "game server id somehow wasn't found", "id", connId) 108 | assert.Assert(gs != "", "game server gameString did not produce a host:port pair", "id", gameId, "id", connId) 109 | 110 | // TODO probably better to just get a full server information 111 | m.logger.Info("game server selected", "host:port", gs, "id", connId) 112 | 113 | return &GameConnectionInfo{ 114 | Id: gameId, 115 | Addr: gs, 116 | }, nil 117 | } 118 | 119 | func (m *MatchMakingServer) Close() { 120 | m.logger.Warn("closing down") 121 | //... hmm 122 | } 123 | 124 | func NewMatchMakingServer(servers GameServer) *MatchMakingServer { 125 | return &MatchMakingServer{ 126 | servers: servers, 127 | logger: slog.Default().With("area", "MatchMakingServer"), 128 | waitingForServer: false, 129 | ready: false, 130 | mutex: sync.Mutex{}, 131 | } 132 | } 133 | 134 | func (m *MatchMakingServer) String() string { 135 | return fmt.Sprintf(`-------- MatchMaking -------- 136 | connected: %v 137 | %s 138 | `, m.listener != nil, m.servers.String()) 139 | } 140 | -------------------------------------------------------------------------------- /pkg/am-proxy/net.go: -------------------------------------------------------------------------------- 1 | package amproxy 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net" 8 | 9 | "vim-arcade.theprimeagen.com/pkg/assert" 10 | "vim-arcade.theprimeagen.com/pkg/packet" 11 | ) 12 | 13 | type AMTCPConnection struct { 14 | conn net.Conn 15 | 16 | connStr string 17 | } 18 | 19 | func CreateTCPConnectionFrom(connString string) (AMConnection, error) { 20 | conn, err := net.Dial("tcp", connString) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | return &AMTCPConnection{ 26 | conn: conn, 27 | connStr: connString, 28 | }, nil 29 | } 30 | 31 | func (a *AMTCPConnection) Read(b []byte) (int, error) { 32 | return a.conn.Read(b) 33 | } 34 | 35 | func (a *AMTCPConnection) Write(b []byte) (int, error) { 36 | return a.conn.Write(b) 37 | } 38 | 39 | func (a *AMTCPConnection) Close() error { 40 | return a.conn.Close() 41 | } 42 | 43 | func (a *AMTCPConnection) String() string { 44 | return a.connStr 45 | } 46 | 47 | func (a *AMTCPConnection) Id() string { 48 | return "AMTCPConnection DOES NOT HAVE ID YET...." 49 | } 50 | 51 | func (a *AMTCPConnection) Addr() string { 52 | return a.connStr 53 | } 54 | 55 | type AMTCPProxy struct { 56 | port uint16 57 | proxy *AMProxy 58 | logger *slog.Logger 59 | listener net.Listener 60 | ready chan struct{} 61 | } 62 | 63 | func NewTCPProxy(proxy *AMProxy, port uint16) AMTCPProxy { 64 | ll := slog.Default().With("area", "AMTCPProxy") 65 | return AMTCPProxy{ 66 | port: port, 67 | logger: ll, 68 | proxy: proxy, 69 | ready: make(chan struct{}, 1), 70 | } 71 | } 72 | 73 | func NewConnection(conn net.Conn) AMConnection { 74 | return &AMTCPConnection{ 75 | conn: conn, 76 | connStr: conn.LocalAddr().String(), 77 | } 78 | } 79 | 80 | func listen(listener net.Listener, ch chan net.Conn) error { 81 | for { 82 | conn, err := listener.Accept() 83 | if err != nil { 84 | return err 85 | } 86 | ch <- conn 87 | } 88 | } 89 | 90 | func (a *AMTCPProxy) WaitForReady(ctx context.Context) { 91 | select { 92 | case <-a.ready: 93 | a.logger.Info("ready") 94 | case <-ctx.Done(): 95 | } 96 | } 97 | 98 | func (a *AMTCPProxy) Run(ctx context.Context) { 99 | // TODO validate that 0.0.0.0 works with docker 100 | portStr := fmt.Sprintf("0.0.0.0:%d", a.port) 101 | 102 | a.logger.Info("server starting", "host:port", portStr) 103 | l, err := net.Listen("tcp4", portStr) 104 | assert.NoError(err, "unable to create proxy connection") 105 | a.logger.Info("server started", "host:port", portStr) 106 | 107 | a.listener = l 108 | 109 | ch := make(chan net.Conn, 10) 110 | go listen(l, ch) 111 | 112 | a.logger.Info("about to ready", "host:port", portStr) 113 | a.ready <- struct{}{} 114 | a.logger.Info("ready sent", "host:port", portStr) 115 | 116 | outer: 117 | for { 118 | select { 119 | case conn := <-ch: 120 | go func() { 121 | a.logger.Info("new net.tcp connection", "laddr", conn.LocalAddr(), "raddr", conn.RemoteAddr()) 122 | err := a.proxy.Add(NewConnection(conn)) 123 | if err != nil { 124 | pkt := packet.CreateErrorPacket(err) 125 | _, err = pkt.Into(conn) 126 | if err != nil { 127 | a.logger.Error("unable to write error packet into connection", "err", err) 128 | } 129 | } 130 | }() 131 | case <-ctx.Done(): 132 | break outer 133 | } 134 | } 135 | } 136 | 137 | func (a *AMTCPProxy) Close() { 138 | if a.listener != nil { 139 | a.listener.Close() 140 | } 141 | 142 | a.proxy.Close() 143 | } 144 | -------------------------------------------------------------------------------- /pkg/api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/hex" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | 13 | "vim-arcade.theprimeagen.com/pkg/assert" 14 | "vim-arcade.theprimeagen.com/pkg/packet" 15 | "vim-arcade.theprimeagen.com/pkg/utils" 16 | ) 17 | 18 | type ClientState int 19 | 20 | const ( 21 | CSInitialized ClientState = iota 22 | CSConnecting 23 | CSAuthenticating 24 | CSConnected 25 | CSDisconnected 26 | ) 27 | 28 | func ClientStateToString(state ClientState) string { 29 | switch state { 30 | case CSInitialized: 31 | return "initialized" 32 | case CSConnecting: 33 | return "connecting" 34 | case CSConnected: 35 | return "connected" 36 | case CSDisconnected: 37 | return "disconnected" 38 | } 39 | 40 | assert.Never("unknown client state", "state", state) 41 | return "" 42 | } 43 | 44 | type hostAndPort struct { 45 | host string 46 | port uint16 47 | } 48 | 49 | type Client struct { 50 | logger *slog.Logger 51 | Host string 52 | Port uint16 53 | conn net.Conn 54 | closed bool 55 | done chan struct{} 56 | ready chan struct{} 57 | mutex sync.Mutex 58 | State ClientState 59 | id [16]byte 60 | framer packet.PacketFramer 61 | ServerId string 62 | } 63 | 64 | func (c *Client) String() string { 65 | return fmt.Sprintf("Host=%s Port=%d", c.Host, c.Port) 66 | } 67 | 68 | func getClientLogger(id []byte) *slog.Logger { 69 | return slog.Default().With("area", "Client").With("id", hex.EncodeToString(id)) 70 | } 71 | 72 | func NewClientFromConnString(hostAndPort string, id [16]byte) Client { 73 | parts := strings.SplitN(hostAndPort, ":", 2) 74 | port, err := strconv.Atoi(parts[1]) 75 | assert.NoError(err, "client was provided a bad string", "hostAndPortString", hostAndPort) 76 | logger := getClientLogger(id[:]) 77 | 78 | return Client{ 79 | State: CSInitialized, 80 | Host: parts[0], 81 | Port: uint16(port), 82 | mutex: sync.Mutex{}, 83 | logger: logger, 84 | id: id, 85 | done: make(chan struct{}, 1), 86 | ready: make(chan struct{}, 1), 87 | closed: false, 88 | framer: packet.NewPacketFramer(), 89 | } 90 | } 91 | 92 | func NewClient(host string, port uint16, id [16]byte) Client { 93 | return Client{ 94 | State: CSInitialized, 95 | Host: host, 96 | Port: uint16(port), 97 | mutex: sync.Mutex{}, 98 | logger: getClientLogger(id[:]), 99 | done: make(chan struct{}, 1), 100 | ready: make(chan struct{}, 1), 101 | id: id, 102 | closed: false, 103 | framer: packet.NewPacketFramer(), 104 | } 105 | } 106 | 107 | func (d *Client) Id() string { 108 | return hex.EncodeToString(d.id[:]) 109 | } 110 | 111 | func (d *Client) Addr() string { 112 | return fmt.Sprintf("%s:%d", d.Host, d.Port) 113 | } 114 | 115 | func (d *Client) Write(data []byte) error { 116 | assert.NotNil(d.conn, "expected the connection to be not nil") 117 | // TODO maybe consider ensure we write all... 118 | _, err := d.conn.Write(data) 119 | return err 120 | } 121 | 122 | func (d *Client) Connect(ctx context.Context) error { 123 | d.State = CSConnecting 124 | d.logger.Info("client connecting to match making") 125 | connStr := fmt.Sprintf("%s:%d", d.Host, d.Port) 126 | d.logger.Info("connect to matchmaking", "conn", connStr) 127 | conn, err := net.Dial("tcp4", connStr) 128 | assert.NoError(err, "could not connect to server") 129 | d.logger.Info("connected to the match making server", "conn", connStr) 130 | 131 | // TODO emit event? 132 | d.State = CSAuthenticating 133 | 134 | pkt := packet.CreateClientAuth(d.id[:]) 135 | 136 | // TODO handle framer errors? 137 | go packet.FrameWithReader(&d.framer, conn) 138 | 139 | pkt.Into(conn) 140 | rsp, ok := <-d.framer.C 141 | 142 | assert.Assert(ok, "expected channel to remain open") 143 | assert.Assert(rsp != nil, "expected a packet") 144 | assert.Assert(rsp.Type() == packet.PacketServerAuthResponse, "expected a auth response back") 145 | 146 | ////////////// wait 147 | assert.Assert(rsp.Data()[0] == 1, "should be authenticated") 148 | 149 | d.logger.Info("auth response", "rsp", rsp) 150 | d.conn = conn 151 | d.ready <- struct{}{} 152 | d.ServerId = packet.ServerAuthGameId(rsp) 153 | 154 | ctxReader := utils.NewContextReader(ctx) 155 | go ctxReader.Read(conn) 156 | 157 | go func() { 158 | for bytes := range ctxReader.Out { 159 | d.logger.Error("message received", "data", string(bytes)) 160 | } 161 | 162 | if err, ok := <-ctxReader.Err; ok && !d.closed { 163 | d.logger.Error("error with client", "error", err) 164 | } 165 | 166 | d.State = CSDisconnected 167 | d.done <- struct{}{} 168 | }() 169 | 170 | return nil 171 | } 172 | 173 | func (d *Client) WaitForDone() { 174 | <-d.done 175 | } 176 | 177 | func (d *Client) WaitForReady() { 178 | <-d.ready 179 | } 180 | 181 | func (d *Client) authenticate() error { 182 | return d.Write(d.id[:]) 183 | } 184 | 185 | func (d *Client) Disconnect() { 186 | d.closed = true 187 | assert.NotNil(d.conn, "attempting to disconnect a non connected client") 188 | 189 | pkt := packet.CreateCloseConnection() 190 | n, err := pkt.Into(d.conn) 191 | if err != nil { 192 | d.logger.Error("unable to write ClientClose to source", "n", n, "err", err) 193 | } 194 | 195 | err = d.conn.Close() 196 | if err != nil { 197 | d.logger.Error("error on close during disconnect", "err", err) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /pkg/api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net" 8 | "os" 9 | "sync" 10 | "time" 11 | 12 | "vim-arcade.theprimeagen.com/pkg/assert" 13 | gameserverstats "vim-arcade.theprimeagen.com/pkg/game-server-stats" 14 | "vim-arcade.theprimeagen.com/pkg/packet" 15 | ) 16 | 17 | var id = 0 18 | 19 | func getId() int { 20 | out := id 21 | id++ 22 | return out 23 | } 24 | 25 | type GameServerRunner struct { 26 | done bool 27 | doneChan chan struct{} 28 | db gameserverstats.GSSRetriever 29 | stats gameserverstats.GameServerConfig 30 | listener net.Listener 31 | logger *slog.Logger 32 | mutex sync.Mutex 33 | } 34 | 35 | func NewGameServerRunner(db gameserverstats.GSSRetriever, stats gameserverstats.GameServerConfig) *GameServerRunner { 36 | logger := slog.Default().With("area", "GameServer") 37 | logger.Warn("new dummy game server", "ID", os.Getenv("ID")) 38 | 39 | return &GameServerRunner{ 40 | logger: logger, 41 | stats: stats, 42 | db: db, 43 | done: false, 44 | doneChan: make(chan struct{}, 1), 45 | mutex: sync.Mutex{}, 46 | } 47 | } 48 | 49 | func (g *GameServerRunner) innerListenForConnections(listener net.Listener) <-chan net.Conn { 50 | ch := make(chan net.Conn, 10) 51 | go func() { 52 | for { 53 | c, err := listener.Accept() 54 | if g.done { 55 | break 56 | } 57 | 58 | assert.NoError(err, "GameServerRunner was unable to accept connection") 59 | ch <- c 60 | } 61 | }() 62 | return ch 63 | } 64 | 65 | // this function is so bad that i need to see a doctor 66 | // which also means i am ready to work at FAANG 67 | func (g *GameServerRunner) incConnections(amount int) { 68 | g.mutex.Lock() 69 | defer g.mutex.Unlock() 70 | 71 | g.stats.Connections += amount 72 | g.stats.Load += float32(amount) * 0.001 73 | 74 | if amount >= 0 { 75 | g.stats.ConnectionsAdded += amount 76 | g.logger.Info("incConnections(added)", "stats", g.stats.String()) 77 | } else { 78 | g.stats.ConnectionsRemoved -= amount 79 | g.logger.Info("incConnections(removed)", "stats", g.stats.String()) 80 | } 81 | 82 | } 83 | 84 | func (g *GameServerRunner) handleConnection(ctx context.Context, conn net.Conn, id int) { 85 | g.incConnections(1) 86 | defer g.incConnections(-1) 87 | 88 | framer := packet.NewPacketFramer() 89 | go packet.FrameWithReader(&framer, conn) 90 | 91 | for { 92 | select { 93 | case <-ctx.Done(): 94 | return 95 | case pkt := <-framer.C: 96 | g.logger.Info("packet received", "packet", pkt.String()) 97 | if packet.IsCloseConnection(pkt) { 98 | g.logger.Info("client sent close command") 99 | return 100 | } 101 | } 102 | } 103 | 104 | } 105 | 106 | func (g *GameServerRunner) Run(outerCtx context.Context) error { 107 | ctx, cancel := context.WithCancel(outerCtx) 108 | 109 | g.logger.Warn("dummy-server#Run started...") 110 | portStr := fmt.Sprintf(":%d", g.stats.Port) 111 | listener, err := net.Listen("tcp4", portStr) 112 | assert.NoError(err, "unable to start server") 113 | 114 | defer func() { 115 | g.done = true 116 | listener.Close() 117 | g.doneChan <- struct{}{} 118 | }() 119 | 120 | go g.handleStatUpdating(ctx) 121 | 122 | g.stats.State = gameserverstats.GSStateReady 123 | err = g.db.Update(g.stats) 124 | assert.NoError(err, "unable to save the stats of the dummy game server on connection") 125 | 126 | g.logger.Warn("dummy-server#Run running...") 127 | 128 | if err != nil { 129 | cancel() 130 | return err 131 | } 132 | 133 | ch := g.innerListenForConnections(listener) 134 | 135 | // TODO do we even need this now that we have ids being transfered up 136 | // via client auth packet?? 137 | connId := 0 138 | 139 | outer: 140 | for { 141 | 142 | // TODO This should be configurable? 143 | timer := time.NewTimer(time.Second * 30) 144 | 145 | g.logger.Info("waiting for connection or ctx done") 146 | select { 147 | case <-timer.C: 148 | if g.stats.Connections == 0 { 149 | if g.stats.State == gameserverstats.GSStateReady { 150 | g.idle() 151 | break 152 | } else if g.stats.State == gameserverstats.GSStateIdle { 153 | g.closeDown() 154 | cancel() 155 | break 156 | } 157 | assert.Never("i should never get to this position", "stats", g.stats) 158 | } 159 | case <-ctx.Done(): 160 | break outer 161 | case c := <-ch: 162 | assert.Assert(g.stats.State != gameserverstats.GSStateClosed, "somehow got a connection when state became closed", "stats", g.stats) 163 | 164 | g.logger.Info("new dummy-server connection", "host", g.stats.Host, "port", g.stats.Port) 165 | go g.handleConnection(ctx, c, connId) 166 | connId++ 167 | g.ready() 168 | } 169 | 170 | timer.Stop() 171 | } 172 | 173 | g.stats.State = gameserverstats.GSStateClosed 174 | err = g.db.Update(g.stats) 175 | assert.NoError(err, "unable to save the stats of the dummy game server on close") 176 | 177 | // lint requires me to do this despite it not being correct... 178 | cancel() 179 | return nil 180 | } 181 | 182 | func (g *GameServerRunner) handleStatUpdating(ctx context.Context) { 183 | timer := time.NewTicker(time.Millisecond * 200) 184 | prev := g.stats 185 | 186 | outer: 187 | for { 188 | select { 189 | case <-ctx.Done(): 190 | break outer 191 | case <-timer.C: 192 | next := g.stats 193 | if !next.Equal(&prev) { 194 | err := g.db.Update(next) 195 | assert.NoError(err, "failed to update stats", "stats", next) 196 | prev = next 197 | } 198 | } 199 | } 200 | 201 | } 202 | 203 | func (g *GameServerRunner) closeDown() { 204 | g.mutex.Lock() 205 | defer g.mutex.Unlock() 206 | 207 | g.stats.State = gameserverstats.GSStateClosed 208 | g.db.Update(g.stats) 209 | g.logger.Info("setting state to closed", "stats", g.stats) 210 | } 211 | 212 | func (g *GameServerRunner) ready() { 213 | if g.stats.State == gameserverstats.GSStateReady { 214 | return 215 | } 216 | 217 | g.mutex.Lock() 218 | defer g.mutex.Unlock() 219 | 220 | g.stats.State = gameserverstats.GSStateIdle 221 | g.db.Update(g.stats) 222 | g.logger.Info("setting state to ready", "stats", g.stats) 223 | } 224 | 225 | func (g *GameServerRunner) idle() { 226 | g.mutex.Lock() 227 | defer g.mutex.Unlock() 228 | 229 | g.stats.State = gameserverstats.GSStateIdle 230 | g.db.Update(g.stats) 231 | g.logger.Info("setting state to idle", "stats", g.stats) 232 | } 233 | 234 | func (g *GameServerRunner) Close() { 235 | if g.listener != nil { 236 | g.done = true 237 | g.listener.Close() 238 | } 239 | } 240 | 241 | func (g *GameServerRunner) Wait() { 242 | <-g.doneChan 243 | } 244 | 245 | func (g *GameServerRunner) Loop() error { 246 | return nil 247 | } 248 | -------------------------------------------------------------------------------- /pkg/api/utils.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net" 5 | ) 6 | 7 | // GetFreePort asks the kernel for a free open port that is ready to use. 8 | func GetFreePort() (port int, err error) { 9 | var a *net.TCPAddr 10 | if a, err = net.ResolveTCPAddr("tcp", "localhost:0"); err == nil { 11 | var l *net.TCPListener 12 | if l, err = net.ListenTCP("tcp", a); err == nil { 13 | defer l.Close() 14 | return l.Addr().(*net.TCPAddr).Port, nil 15 | } 16 | } 17 | return 18 | } 19 | 20 | 21 | func GetHostAndPort() (string, int) { 22 | 23 | port, err := GetFreePort() 24 | if err != nil { 25 | port = 42069 26 | } 27 | return "0.0.0.0", port 28 | } 29 | 30 | -------------------------------------------------------------------------------- /pkg/assert/assert.go: -------------------------------------------------------------------------------- 1 | package assert 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log/slog" 7 | "os" 8 | "reflect" 9 | "runtime/debug" 10 | ) 11 | 12 | // TODO using slog for logging 13 | type AssertData interface { 14 | Dump() string 15 | } 16 | type AssertFlush interface { 17 | Flush() 18 | } 19 | 20 | var flushes []AssertFlush = []AssertFlush{} 21 | var assertData map[string]AssertData = map[string]AssertData{} 22 | var writer io.Writer 23 | 24 | func AddAssertData(key string, value AssertData) { 25 | assertData[key] = value 26 | } 27 | 28 | func RemoveAssertData(key string) { 29 | delete(assertData, key) 30 | } 31 | 32 | func AddAssertFlush(flusher AssertFlush) { 33 | flushes = append(flushes, flusher) 34 | } 35 | 36 | func ToWriter(w io.Writer) { 37 | writer = w 38 | } 39 | 40 | func runAssert(msg string, args ...interface{}) { 41 | // There is a bit of a issue here. if you flush you cannot assert 42 | // cannot be reentrant 43 | // TODO I am positive i could create some sort of latching that prevents the 44 | // reentrant problem 45 | for _, f := range flushes { 46 | f.Flush() 47 | } 48 | 49 | slogValues := []interface{}{ 50 | "msg", 51 | msg, 52 | "area", 53 | "Assert", 54 | } 55 | slogValues = append(slogValues, args...) 56 | fmt.Fprintf(os.Stderr, "ARGS: %+v\n", args) 57 | 58 | for k, v := range assertData { 59 | slogValues = append(slogValues, k, v.Dump()) 60 | } 61 | 62 | fmt.Fprintf(os.Stderr, "ASSERT\n") 63 | for i := 0; i < len(slogValues); i += 2 { 64 | fmt.Fprintf(os.Stderr, " %s=%v\n", slogValues[i], slogValues[i + 1]) 65 | } 66 | fmt.Fprintln(os.Stderr, string(debug.Stack())) 67 | os.Exit(1) 68 | } 69 | 70 | // TODO Think about passing around a context for debugging purposes 71 | func Assert(truth bool, msg string, data ...any) { 72 | if !truth { 73 | runAssert(msg, data...) 74 | } 75 | } 76 | 77 | func Nil(item any, msg string, data ...any) { 78 | slog.Info("Nil Check", "item", item) 79 | if item == nil { 80 | return 81 | } 82 | 83 | slog.Error("Nil#not nil encountered") 84 | runAssert(msg, data...) 85 | } 86 | 87 | func NotNil(item any, msg string, data ...any) { 88 | if item == nil || reflect.ValueOf(item).Kind() == reflect.Ptr && reflect.ValueOf(item).IsNil() { 89 | slog.Error("NotNil#nil encountered") 90 | runAssert(msg, data...) 91 | } 92 | } 93 | 94 | func Never(msg string, data ...any) { 95 | runAssert(msg, data...) 96 | } 97 | 98 | func NoError(err error, msg string, data ...any) { 99 | if err != nil { 100 | data = append(data, "error", err) 101 | runAssert(msg, data...) 102 | } 103 | } 104 | 105 | 106 | -------------------------------------------------------------------------------- /pkg/cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log/slog" 7 | "os" 8 | "os/exec" 9 | 10 | "vim-arcade.theprimeagen.com/pkg/assert" 11 | ) 12 | 13 | type writerFn = func(b []byte) (int, error) 14 | 15 | type fnAsWriter struct { 16 | fn writerFn 17 | } 18 | 19 | func (f *fnAsWriter) Write(b []byte) (int, error) { 20 | return f.fn(b) 21 | } 22 | 23 | type Cmder struct { 24 | Err io.Writer 25 | Out io.Writer 26 | In io.Reader 27 | Name string 28 | Args []string 29 | cmd *exec.Cmd 30 | stdin io.WriteCloser 31 | stdout io.ReadCloser 32 | stderr io.ReadCloser 33 | ctx context.Context 34 | done chan struct{} 35 | } 36 | 37 | func NewCmder(name string, ctx context.Context) *Cmder { 38 | return &Cmder{ 39 | Err: nil, 40 | Out: nil, 41 | Name: name, 42 | Args: []string{}, 43 | ctx: ctx, 44 | done: make(chan struct{}, 1), 45 | } 46 | } 47 | 48 | func (c *Cmder) AddVArg(value string) *Cmder { 49 | c.Args = append(c.Args, value) 50 | return c; 51 | } 52 | 53 | func (c *Cmder) AddVArgv(value []string) *Cmder { 54 | for _, v := range value { 55 | c.Args = append(c.Args, v) 56 | } 57 | return c; 58 | } 59 | 60 | func (c *Cmder) AddKVArg(name string, value string) *Cmder { 61 | c.Args = append(c.Args, name, value) 62 | return c; 63 | } 64 | 65 | func (c *Cmder) WithErrFn(fn writerFn) *Cmder { 66 | c.Err = &fnAsWriter{fn: fn} 67 | return c; 68 | } 69 | 70 | func (c *Cmder) WithErr(writer io.Writer) *Cmder { 71 | c.Err = writer; 72 | return c; 73 | } 74 | 75 | func (c *Cmder) WithOutFn(fn writerFn) *Cmder { 76 | c.Out = &fnAsWriter{fn: fn} 77 | return c; 78 | } 79 | 80 | func (c *Cmder) WithOut(writer io.Writer) *Cmder { 81 | c.Out = writer; 82 | return c; 83 | } 84 | 85 | func (c *Cmder) Close() { 86 | err := c.cmd.Process.Kill(); 87 | if err != nil { 88 | slog.Error("cannot close cmder", "err", err) 89 | } 90 | 91 | if c.stdout != nil { 92 | if err := c.stdout.Close(); err != nil { 93 | slog.Error("cannot close cmder stdout", "err", err) 94 | } 95 | } 96 | 97 | if c.stderr != nil { 98 | if err := c.stderr.Close(); err != nil { 99 | slog.Error("cannot close cmder stderr", "err", err) 100 | } 101 | } 102 | } 103 | 104 | func (c *Cmder) Done() { 105 | <-c.done 106 | } 107 | 108 | func (c *Cmder) WriteLine(b []byte) error { 109 | read := 0 110 | for read < len(b) { 111 | n, err := c.stdin.Write(b[read:]) 112 | if err != nil { 113 | return err 114 | } 115 | read += n 116 | } 117 | if b[len(b) - 1] != '\n' { 118 | _, _ = c.stdin.Write([]byte{'\n'}) 119 | } 120 | 121 | return nil 122 | } 123 | 124 | func (c *Cmder) Run(env []string) error { 125 | assert.Assert(c.Out != nil, "you should never spawn a cmd without at least listening to stdout") 126 | assert.Assert(c.Name != "", "you need to provide a name for the program to run") 127 | 128 | c.cmd = exec.Command(c.Name, c.Args...) 129 | if len(env) > 0 { 130 | c.cmd.Env = append(os.Environ(), env...) 131 | } 132 | 133 | stdin, err := c.cmd.StdinPipe() 134 | if err != nil { 135 | return err 136 | } 137 | c.stdin = stdin 138 | 139 | stdout, err := c.cmd.StdoutPipe() 140 | if err != nil { 141 | return err 142 | } 143 | c.stdout = stdout 144 | 145 | stderr, err := c.cmd.StderrPipe() 146 | if err != nil { 147 | return err 148 | } 149 | c.stderr = stderr 150 | 151 | err = c.cmd.Start() 152 | if err != nil { 153 | return err 154 | } 155 | 156 | go func() { 157 | <-c.ctx.Done() 158 | c.Close() 159 | }() 160 | 161 | go io.Copy(c.Out, stdout) 162 | if c.Err != nil { 163 | go io.Copy(c.Err, stderr) 164 | } 165 | 166 | err = c.cmd.Wait() 167 | c.done<-struct{}{} 168 | return err 169 | } 170 | 171 | -------------------------------------------------------------------------------- /pkg/ctrlc/ctlrc.go: -------------------------------------------------------------------------------- 1 | package ctrlc 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | "os/signal" 8 | "time" 9 | ) 10 | 11 | 12 | func HandleCtrlC(cancel context.CancelFunc) { 13 | c := make(chan os.Signal, 1) 14 | signal.Notify(c, os.Interrupt) 15 | go func() { 16 | <-c 17 | cancel() 18 | slog.Info("ctrl-c", "area", "ctrlc") 19 | time.Sleep(time.Millisecond * 250) 20 | // Run Cleanup 21 | os.Exit(1) 22 | }() 23 | } 24 | 25 | 26 | -------------------------------------------------------------------------------- /pkg/game-server-stats/sqlite.go: -------------------------------------------------------------------------------- 1 | package gameserverstats 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "strings" 9 | 10 | "github.com/jmoiron/sqlx" 11 | _ "github.com/tursodatabase/go-libsql" 12 | "vim-arcade.theprimeagen.com/pkg/assert" 13 | ) 14 | 15 | func checkTableExists(db *sqlx.DB) bool { 16 | query := `SELECT name 17 | FROM sqlite_master 18 | WHERE type='table' AND name='GameServerConfigs';` 19 | 20 | var tableName string 21 | err := db.Get(&tableName, query) 22 | assert.NoError(err, "error while checking for the table existing") 23 | return tableName == "GameServerConfigs" 24 | } 25 | 26 | func deleteTable(db *sqlx.DB) error { 27 | query := `DROP TABLE IF EXISTS GameServerConfigs;` 28 | _, err := db.Exec(query) 29 | return err 30 | } 31 | 32 | // createTable creates the GameServerConfigs table 33 | func (s *Sqlite) CreateGameServerConfigs() error { 34 | // TODO validate this: apparently INTEGER for last_updated makes life 35 | // easier for calculations?? 36 | query := ` 37 | CREATE TABLE GameServerConfigs ( 38 | id TEXT PRIMARY KEY, 39 | state TEXT, 40 | connections INTEGER, 41 | connections_added INTEGER, 42 | connections_removed INTEGER, 43 | last_updated INTERGER, 44 | load REAL, 45 | host TEXT, 46 | port INTEGER 47 | );` 48 | 49 | _, err := s.db.Exec(query) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | var createLoadIndex = `CREATE INDEX idx_load ON GameServerConfigs (Load);` 55 | _, err = s.db.Exec(createLoadIndex) 56 | 57 | return err 58 | } 59 | 60 | type SqliteFile struct { 61 | Stats []GameServerConfig `json:"stats"` 62 | } 63 | 64 | type Sqlite struct { 65 | db *sqlx.DB 66 | logger *slog.Logger 67 | } 68 | 69 | func getLogger() *slog.Logger { 70 | return slog.Default().With("area", "Sqlite") 71 | } 72 | 73 | func ClearSQLiteFiles(path string) { 74 | os.Remove(path) 75 | os.Remove(fmt.Sprintf("%s-shm", path)) 76 | os.Remove(fmt.Sprintf("%s-wal", path)) 77 | } 78 | 79 | func NewSqlite(path string) *Sqlite { 80 | logger := getLogger() 81 | db, err := sqlx.Open("libsql", path) 82 | assert.NoError(err, "failed to open db") 83 | logger.Warn("New Sqlite", "path", path) 84 | return &Sqlite{ 85 | db: db, 86 | logger: logger, 87 | } 88 | } 89 | 90 | func (s *Sqlite) Close() error { 91 | return s.db.Close() 92 | } 93 | 94 | func (s *Sqlite) setPragma(name string, value string) { 95 | row := s.db.QueryRowx(fmt.Sprintf("PRAGMA %s=%s;", name, value)) 96 | var v string 97 | err := row.Scan(&v) 98 | assert.NoError(err, "could not scan pragma row result", "name", name, "value", value) 99 | s.logger.Warn(name, "value", v) 100 | } 101 | 102 | func (s *Sqlite) SetSqliteModes() { 103 | s.setPragma("busy_timeout", "3000") 104 | s.setPragma("journal_mode", "WAL") 105 | } 106 | 107 | func (s *Sqlite) GetServerCount() int { 108 | selectQuery := `SELECT COUNT(*) 109 | FROM GameServerConfigs;` 110 | 111 | var count int 112 | err := s.db.Get(&count, selectQuery) 113 | assert.NoError(err, "unable to get server count") 114 | 115 | return count 116 | } 117 | 118 | func (s *Sqlite) GetTotalConnectionCount() GameServecConfigConnectionStats { 119 | sumQuery := `SELECT CAST(TOTAL(connections) AS INT) AS connections, 120 | CAST(TOTAL(connections_added) AS INT) AS connections_added, 121 | CAST(TOTAL(connections_removed) AS INT) AS connections_removed 122 | FROM GameServerConfigs;` 123 | 124 | var counts GameServecConfigConnectionStats 125 | err := s.db.Get(&counts, sumQuery) 126 | assert.NoError(err, "unable to get total connection count") 127 | 128 | return counts 129 | } 130 | 131 | func (s *Sqlite) Update(stat GameServerConfig) error { 132 | s.logger.Info("Updating", "stat", stat) 133 | query := `INSERT OR REPLACE INTO GameServerConfigs (id, state, connections, connections_added, connections_removed, load, host, port, last_updated) 134 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'));` 135 | 136 | // TODO probably don't need to update every 137 | res, err := s.db.Exec(query, stat.Id, stat.State, stat.Connections, stat.ConnectionsAdded, stat.ConnectionsRemoved, stat.Load, stat.Host, stat.Port) 138 | n, err := res.RowsAffected() 139 | s.logger.Info("update complete", "rows affected", n, "error", err) 140 | 141 | return err 142 | } 143 | 144 | func EnsureSqliteURI(path string) string { 145 | if strings.HasPrefix(path, "file:") || 146 | strings.HasPrefix(path, "https://") { 147 | return path 148 | } 149 | 150 | return "file:" + path 151 | } 152 | 153 | 154 | func (j *Sqlite) Run(ctx context.Context) { 155 | <-ctx.Done() 156 | j.db.Close() 157 | j.logger.Warn("Sqlite finished running") 158 | } 159 | 160 | func (s *Sqlite) GetConfigByHostAndPort(host string, port uint16) (*GameServerConfig, error) { 161 | query := `SELECT * FROM GameServerConfigs WHERE host = ? AND port = ?;` 162 | s.logger.Error("GetConfigByHostAndPort", "query", query) 163 | config := []GameServerConfig{} 164 | err := s.db.Select(&config, query, host, port) 165 | 166 | s.logger.Error("GetConfigByHostAndPort", "query", query, "config", config, "error", err) 167 | 168 | if len(config) == 1 { 169 | return &config[0], err 170 | } 171 | return nil, err 172 | } 173 | 174 | func (s *Sqlite) DeleteGameServerConfig(id string) { 175 | assert.Assert(os.Getenv("ENV") == "TESTING", "can only delete server configs while testing") 176 | query := `DELETE FROM table_name WHERE column_name = ?;` 177 | _, err := s.db.Exec(query) 178 | assert.NoError(err, "there should be no error when deleting a row.") 179 | } 180 | 181 | func (s *Sqlite) GetAllGameServerConfigs() ([]GameServerConfig, error) { 182 | var configs []GameServerConfig 183 | query := `SELECT id, state, connections, load, host, port FROM GameServerConfigs;` 184 | 185 | err := s.db.Select(&configs, query) 186 | if err != nil { 187 | return nil, err 188 | } 189 | 190 | return configs, nil 191 | } 192 | 193 | func (s *Sqlite) GetById(id string) *GameServerConfig { 194 | g := []GameServerConfig{} 195 | s.db.Select(&g, `SELECT * 196 | FROM GameServerConfigs 197 | WHERE id=?;`, id) 198 | if len(g) == 1 { 199 | s.logger.Info("GetById", "id", id, "stat", g[0].String()) 200 | return &g[0] 201 | } 202 | return nil 203 | } 204 | 205 | func (s *Sqlite) GetServersByUtilization(maxLoad float64) []GameServerConfig { 206 | var g []GameServerConfig 207 | s.db.Select(&g, `SELECT * 208 | FROM GameServerConfigs 209 | WHERE load < ? AND state == ? 210 | ORDER BY load DESC;`, maxLoad, GSStateReady) 211 | s.logger.Info("GetServersByUtilization", "maxLoad", maxLoad, "count", len(g)) 212 | return g 213 | } 214 | -------------------------------------------------------------------------------- /pkg/game-server-stats/stats.go: -------------------------------------------------------------------------------- 1 | package gameserverstats 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | type State int 9 | 10 | const ( 11 | GSStateInitializing State = iota 12 | GSStateReady 13 | GSStateIdle 14 | GSStateClosed 15 | ) 16 | 17 | type GameServecConfigConnectionStats struct { 18 | Connections int `db:"connections"` 19 | ConnectionsAdded int `db:"connections_added"` 20 | ConnectionsRemoved int `db:"connections_removed"` 21 | } 22 | 23 | func (g *GameServecConfigConnectionStats) String() string { 24 | return fmt.Sprintf("Conns=%d Added=%d Removed=%d", g.Connections, g.ConnectionsAdded, g.ConnectionsRemoved) 25 | } 26 | 27 | func (g *GameServecConfigConnectionStats) Equal(other *GameServecConfigConnectionStats) bool { 28 | return g.Connections == other.Connections && 29 | g.ConnectionsRemoved == other.ConnectionsRemoved && 30 | g.ConnectionsAdded == other.ConnectionsAdded 31 | } 32 | 33 | type GameServerConfig struct { 34 | State State `db:"state"` 35 | 36 | Id string `db:"id"` 37 | 38 | Connections int `db:"connections"` 39 | ConnectionsAdded int `db:"connections_added"` 40 | ConnectionsRemoved int `db:"connections_removed"` 41 | 42 | LastUpdateMS int64 `db:"last_updated"` 43 | 44 | // TODO possible? 45 | Load float32 `db:"load"` 46 | 47 | Host string `db:"host"` 48 | 49 | Port int `db:"port"` 50 | } 51 | 52 | func (g *GameServerConfig) Equal(other *GameServerConfig) bool { 53 | return g.Id == other.Id && 54 | g.Connections == other.Connections && 55 | g.ConnectionsAdded == other.ConnectionsAdded && 56 | g.ConnectionsRemoved == other.ConnectionsRemoved 57 | } 58 | 59 | func stateToString(state State) string { 60 | switch state { 61 | case GSStateInitializing: 62 | return "init" 63 | case GSStateReady: 64 | return "ready" 65 | case GSStateIdle: 66 | return "idle" 67 | case GSStateClosed: 68 | return "closed" 69 | default: 70 | return "unknown" 71 | } 72 | } 73 | 74 | func (g *GameServerConfig) String() string { 75 | return fmt.Sprintf("Server(%s): Addr=%s Conns=%d Load=%f State=%s", g.Id, g.Addr(), g.Connections, g.Load, stateToString(g.State)) 76 | } 77 | 78 | func (g *GameServerConfig) Addr() string { 79 | return fmt.Sprintf("%s:%d", g.Host, g.Port) 80 | } 81 | 82 | // TODO I don't know what to call this thing... 83 | type GSSRetriever interface { 84 | GetById(string) *GameServerConfig 85 | GetAllGameServerConfigs() ([]GameServerConfig, error) 86 | Run(ctx context.Context) 87 | GetServersByUtilization(maxLoad float64) []GameServerConfig 88 | Update(stats GameServerConfig) error 89 | GetServerCount() int 90 | GetTotalConnectionCount() GameServecConfigConnectionStats 91 | } 92 | -------------------------------------------------------------------------------- /pkg/packet/legacy-packet.go: -------------------------------------------------------------------------------- 1 | package packet 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "vim-arcade.theprimeagen.com/pkg/assert" 9 | ) 10 | 11 | func LegacyClientId(id string) []byte { 12 | return []byte(fmt.Sprintf("hello:%s", id)) 13 | } 14 | 15 | func ParseClientId(packet string) (int, error) { 16 | assert.Assert(strings.HasPrefix(packet, "hello:"), "passed in a non hello packet to ParseClientId", "packet", packet) 17 | return strconv.Atoi(strings.TrimSpace(packet[6:])) 18 | } 19 | 20 | func LegacyClientClose() []byte { 21 | return []byte("close") 22 | } 23 | 24 | func LegacyIsEmpty(data []byte) bool { 25 | return len(data) == 0 26 | } 27 | 28 | func LegacyIsClientClosed(data []byte) bool { 29 | return string(data) == "close" 30 | } 31 | 32 | -------------------------------------------------------------------------------- /pkg/packet/packet.go: -------------------------------------------------------------------------------- 1 | package packet 2 | 3 | import ( 4 | "encoding/binary" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | 10 | "vim-arcade.theprimeagen.com/pkg/assert" 11 | prettylog "vim-arcade.theprimeagen.com/pkg/pretty-log" 12 | "vim-arcade.theprimeagen.com/pkg/utils" 13 | ) 14 | 15 | const VERSION uint8 = 1 16 | 17 | const HEADER_SIZE = 4 18 | const TYPE_ENC_INDEX = 1 19 | const MAX_TYPE_SIZE = 0x3F 20 | const HEADER_LENGTH_OFFSET = 2 21 | const PACKET_MAX_SIZE = 1024 22 | const PACKET_PAYLOAD_SIZE = 1024 - HEADER_SIZE 23 | 24 | const PACKET_AUTH_SIZE = 16 + HEADER_SIZE 25 | 26 | var PacketMaxSizeExceeded = fmt.Errorf("Packet length has exceeded allowed size of %d", PACKET_PAYLOAD_SIZE - 1) 27 | var PacketVersionMismatch = fmt.Errorf("Expected packet version to equal %d", VERSION) 28 | var PacketBufferNotBigEnough = fmt.Errorf("Buffer could not fit the entire packet") 29 | 30 | type Encoding uint8 31 | 32 | const ( 33 | EncodingJSON Encoding = iota 34 | EncodingString 35 | EncodingBytes 36 | EncodingUNUSED2 37 | ) 38 | 39 | type PacketType uint8 40 | 41 | const ( 42 | PacketError PacketType = iota 43 | PacketMessage 44 | PacketClientAuth 45 | PacketServerAuthResponse 46 | PacketGameSettings 47 | PacketItem 48 | PacketItemUpdate 49 | PacketCloseConnection 50 | ) 51 | 52 | type Packet struct { 53 | data []byte 54 | len int 55 | } 56 | 57 | type PacketEncoder interface { 58 | io.Reader 59 | Type() uint8 60 | Encoding() Encoding 61 | } 62 | 63 | func TypeToString(t PacketType) string { 64 | // TODO could be a sweet short :) 65 | switch t { 66 | case PacketError: return "Error" 67 | case PacketMessage: return "Message" 68 | case PacketClientAuth: return "ClientAuth" 69 | case PacketServerAuthResponse: return "ServerAuthResponse" 70 | case PacketGameSettings: return "GameSettings" 71 | case PacketItem: return "Item" 72 | case PacketItemUpdate: return "ItemUpdate" 73 | case PacketCloseConnection: return "CloseConnection" 74 | default: 75 | assert.Never("packet unknown", "type", t) 76 | } 77 | return "" 78 | } 79 | 80 | func CreateTypeAndEncodingByte(t PacketType, enc Encoding) byte { 81 | return uint8(enc << 6) | uint8(t) 82 | } 83 | 84 | func PacketFromParts(t PacketType, enc Encoding, data []byte) Packet { 85 | assert.Assert(len(data) < PACKET_PAYLOAD_SIZE, "packet size is too large", "MAX", PACKET_MAX_SIZE - 1, "received", len(data)) 86 | assert.Assert(t < MAX_TYPE_SIZE, "max type size exceeded", "MAX", MAX_TYPE_SIZE - 1, "received", t) 87 | 88 | buf := append([]byte{ 89 | VERSION, 90 | CreateTypeAndEncodingByte(t, enc), 91 | 0, 92 | 0, 93 | }, data...) 94 | 95 | binary.BigEndian.PutUint16(buf[HEADER_LENGTH_OFFSET:], uint16(len(data))) 96 | 97 | return Packet{ 98 | data: buf, 99 | len: len(buf), 100 | } 101 | } 102 | 103 | func CreateMessage(msg string) Packet { 104 | return PacketFromParts(PacketMessage, EncodingString, []byte(msg)) 105 | } 106 | 107 | func CreateErrorPacket(err error) Packet { 108 | return PacketFromParts(PacketError, EncodingString, []byte(err.Error())) 109 | } 110 | 111 | func CreateServerAuthResponse(accepted bool, id string) Packet { 112 | var b uint8 = 1 113 | if !accepted { 114 | b = 0 115 | } 116 | 117 | data := []byte{ b } 118 | data = append(data, []byte(id)...) 119 | 120 | return PacketFromParts(PacketServerAuthResponse, EncodingBytes, data) 121 | } 122 | 123 | func CreateCloseConnection() Packet { 124 | // i think i have a 0 packet size assert... 125 | // lets find out 126 | return PacketFromParts(PacketCloseConnection, EncodingBytes, []byte{}) 127 | } 128 | 129 | func CreateClientAuth(id []byte) Packet { 130 | assert.Assert(len(id) == 16, "cannot create a auth packet that isn't 16 bytes", "len", len(id)) 131 | return PacketFromParts(PacketClientAuth, EncodingBytes, id) 132 | } 133 | 134 | func getPacketLength(data []byte) uint16 { 135 | return binary.BigEndian.Uint16(data[HEADER_LENGTH_OFFSET:]) 136 | } 137 | 138 | func PacketFromBytes(data []byte) Packet { 139 | assert.Assert(data[0] == VERSION, "version mismatch: this should be handled by the framer before packet is created", "VERSION", VERSION, "provided", data[0]) 140 | 141 | dataLen := len(data) - HEADER_SIZE 142 | assert.Assert(dataLen >= 0, "packets must contain some sort of data") 143 | 144 | encodedLen := getPacketLength(data) 145 | assert.Assert(dataLen == int(encodedLen), "the data buffer provided has a length mismatch", "expected length", dataLen, "encoded length", encodedLen) 146 | 147 | return Packet{ 148 | data: data, 149 | len: len(data), 150 | } 151 | } 152 | 153 | func NewPacket(encoder PacketEncoder) Packet { 154 | b := make([]byte, PACKET_MAX_SIZE, PACKET_MAX_SIZE) 155 | 156 | enc := b[HEADER_SIZE:] 157 | n, err := encoder.Read(enc) 158 | assert.NoError(err, "i should never fail on encoding a packet") 159 | assert.Assert(n != PACKET_PAYLOAD_SIZE, "max packet size exceeded", "MAX_SIZE", PACKET_PAYLOAD_SIZE) 160 | 161 | t := encoder.Type() 162 | assert.Assert(t <= (0x40 - 1), "type has exceeded allowed size", "type", t) 163 | 164 | b[0] = VERSION 165 | b[1] = uint8(encoder.Encoding() << 6) | encoder.Type() 166 | 167 | binary.BigEndian.PutUint16(b[2:], uint16(n)) 168 | 169 | return Packet{data: b, len: n + HEADER_SIZE} 170 | } 171 | 172 | func (p *Packet) Into(writer io.Writer) (int, error) { 173 | return writer.Write(p.data[:p.len]) 174 | } 175 | 176 | func (p *Packet) Len() uint16 { 177 | return binary.BigEndian.Uint16(p.data[2:]) 178 | } 179 | 180 | func (p *Packet) Data() []byte { 181 | return p.data[HEADER_SIZE:p.len] 182 | } 183 | 184 | // shit 185 | // shit 186 | func (p *Packet) Type() PacketType { 187 | return PacketType(p.data[TYPE_ENC_INDEX] & 0x3F) 188 | } 189 | 190 | func (p *Packet) Encoding() Encoding { 191 | return Encoding((p.data[TYPE_ENC_INDEX] >> 6) & 0x3) 192 | } 193 | 194 | func (p *Packet) Read(data []byte) (int, error) { 195 | if len(data) < p.len { 196 | return 0, PacketBufferNotBigEnough 197 | } 198 | copy(data, p.data[0:p.len]) 199 | return p.len, nil 200 | } 201 | 202 | func (p *Packet) String() string { 203 | prettyData := utils.PrettyPrintBytes(p.Data(), 16) 204 | return fmt.Sprintf("Packet(v=%d, t=%s, enc=%d, len=%d) -> \"%s\"", p.data[0], TypeToString(p.Type()), p.Encoding(), p.Len(), prettyData) 205 | } 206 | 207 | type PacketFramer struct { 208 | buf []byte 209 | idx int 210 | C chan *Packet 211 | } 212 | 213 | func NewPacketFramer() PacketFramer { 214 | return PacketFramer{ 215 | buf: make([]byte, PACKET_PAYLOAD_SIZE, PACKET_PAYLOAD_SIZE), 216 | C: make(chan *Packet, 10), 217 | } 218 | } 219 | 220 | func (p *PacketFramer) Push(data []byte) error { 221 | n := copy(p.buf[p.idx:], data) 222 | 223 | if n < len(data) { 224 | p.buf = append(p.buf, data[n:]...) 225 | } 226 | 227 | p.idx += len(data) 228 | 229 | prettylog.Trace(slog.Default(),"PacketFramer received bytes", "len", p.idx, "pretty bytes", utils.PrettyPrintBytes(p.buf, p.idx)) 230 | 231 | for { 232 | pkt, err := p.pull() 233 | if err != nil || pkt == nil { 234 | return err 235 | } 236 | 237 | p.C <- pkt 238 | } 239 | } 240 | 241 | func (p *PacketFramer) pull() (*Packet, error) { 242 | if p.idx < HEADER_SIZE { 243 | return nil, nil 244 | } 245 | 246 | if p.buf[0] != VERSION { 247 | return nil, errors.Join( 248 | PacketVersionMismatch, 249 | fmt.Errorf("received version: %d", p.buf[0])) 250 | } 251 | 252 | packetLen := getPacketLength(p.buf) 253 | fullLen := packetLen + HEADER_SIZE 254 | if packetLen == PACKET_PAYLOAD_SIZE { 255 | return nil, PacketMaxSizeExceeded 256 | } 257 | 258 | if fullLen <= uint16(p.idx) { 259 | out := make([]byte, fullLen, fullLen) 260 | copy(out, p.buf[:fullLen]) 261 | copy(p.buf, p.buf[fullLen:]) 262 | p.idx = p.idx - int(fullLen) 263 | 264 | pkt := PacketFromBytes(out) 265 | return &pkt, nil 266 | } 267 | 268 | return nil, nil 269 | } 270 | 271 | func FrameWithReader(framer *PacketFramer, reader io.Reader) error { 272 | data := make([]byte, 100, 100) 273 | for { 274 | n, err := reader.Read(data) 275 | if err != nil { 276 | return err 277 | } 278 | 279 | framer.Push(data[:n]) 280 | } 281 | } 282 | 283 | func IsCloseConnection(p *Packet) bool { 284 | return p.Type() == PacketCloseConnection 285 | } 286 | func IsServerAuth(p *Packet) bool { 287 | return p.Type() == PacketServerAuthResponse 288 | } 289 | 290 | // ok here is the other verson of the same thing 291 | func ServerAuthGameId(p *Packet) string { 292 | assert.Assert(p.Type() == PacketServerAuthResponse, "cannot cast the packet into a server auth packet", "packet", p.String()) 293 | return string(p.data[HEADER_SIZE + 1:]) 294 | } 295 | -------------------------------------------------------------------------------- /pkg/packet/packet_test.go: -------------------------------------------------------------------------------- 1 | package packet_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "errors" 7 | "io" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | "vim-arcade.theprimeagen.com/pkg/packet" 12 | ) 13 | 14 | type TestEncoding struct { } 15 | 16 | func (t *TestEncoding) Encoding() packet.Encoding { 17 | return packet.EncodingString 18 | } 19 | 20 | func (t *TestEncoding) Type() uint8 { 21 | return 63 // doesn't really matter 22 | } 23 | 24 | var testEncoding = []byte("hello encoding") 25 | func (t *TestEncoding) Read(data []byte) (int, error) { 26 | return copy(data, testEncoding), nil 27 | } 28 | 29 | func TestPacketCreation(t *testing.T) { 30 | te := TestEncoding{} 31 | p := packet.NewPacket(&te) 32 | 33 | data := make([]byte, 0, 100) 34 | buf := bytes.NewBuffer(data) 35 | 36 | n, err := p.Into(buf) 37 | 38 | require.NoError(t, err, "into had an error") 39 | require.Equal(t, n, packet.HEADER_SIZE + len(testEncoding), "expected n to have the same value as len of testEncoding") 40 | require.Equal(t, testEncoding, data[packet.HEADER_SIZE:n], "expected encoding to have the same value as testEncoding") 41 | require.Equal(t, testEncoding, p.Data(), "expected encoding to have the same value as testEncoding") 42 | } 43 | 44 | func TestPacketFramer(t *testing.T) { 45 | te := TestEncoding{} 46 | p := packet.NewPacket(&te) 47 | 48 | data := make([]byte, 0, 100) 49 | buf := bytes.NewBuffer(data) 50 | 51 | BUF_COUNT := 5 52 | 53 | for range BUF_COUNT { 54 | _, err := p.Into(buf) 55 | require.NoError(t, err, "unable to write into buffer") 56 | } 57 | 58 | framer := packet.NewPacketFramer() 59 | framer.Push(buf.Bytes()) 60 | 61 | for range BUF_COUNT { 62 | pkt := <-framer.C 63 | require.Equal(t, pkt.Data(), testEncoding) 64 | } 65 | 66 | var pkt *packet.Packet 67 | select { 68 | case p := <-framer.C: 69 | pkt = p 70 | default: 71 | } 72 | 73 | require.Equal(t, pkt, (*packet.Packet)(nil)) 74 | } 75 | 76 | func TestReaderFramer(t *testing.T) { 77 | te := TestEncoding{} 78 | p := packet.NewPacket(&te) 79 | 80 | data := make([]byte, 0, 100) 81 | buf := bytes.NewBuffer(data) 82 | 83 | BUF_COUNT := 5 84 | 85 | for range BUF_COUNT { 86 | _, err := p.Into(buf) 87 | require.NoError(t, err, "unable to write into buffer") 88 | } 89 | 90 | framer := packet.NewPacketFramer() 91 | 92 | var err error = nil 93 | go func() { 94 | err = packet.FrameWithReader(&framer, buf) 95 | if !errors.Is(err, io.EOF) { 96 | require.NoError(t, err) 97 | } 98 | }() 99 | 100 | for range BUF_COUNT { 101 | pkt := <- framer.C 102 | require.Equal(t, pkt.Data(), testEncoding) 103 | } 104 | 105 | var pkt *packet.Packet = nil 106 | select { 107 | case pkt = <- framer.C: 108 | default: 109 | } 110 | require.Equal(t, pkt, (*packet.Packet)(nil)) 111 | if !errors.Is(err, io.EOF) { 112 | require.NoError(t, err) 113 | } 114 | } 115 | 116 | func TestPacketFromParts(t *testing.T) { 117 | p := packet.CreateClientAuth([]byte{ 118 | 0, 4, 2, 0, 119 | 1, 3, 3, 7, 120 | 0, 0, 4, 2, 121 | 0, 0, 6, 9, 122 | }) 123 | 124 | data := make([]byte, 0, 100) 125 | buf := bytes.NewBuffer(data) 126 | 127 | var err error = nil 128 | _, err = p.Into(buf) 129 | require.NoError(t, err, "unable to write into buffer") 130 | 131 | pkt := buf.Bytes() 132 | pktFromBytes := packet.PacketFromBytes(pkt) 133 | bLen := binary.BigEndian.Uint16(pkt[2:]) 134 | 135 | require.Equal(t, pktFromBytes, p) 136 | require.Equal(t, pkt[0], packet.VERSION) 137 | require.Equal(t, pkt[1], packet.CreateTypeAndEncodingByte(packet.PacketClientAuth, packet.EncodingBytes)) 138 | require.Equal(t, bLen, uint16(16)) 139 | } 140 | -------------------------------------------------------------------------------- /pkg/packet/protocol.md: -------------------------------------------------------------------------------- 1 | ## Packet envelope 2 | 3 | all data is interpreted as Network Ordering 4 | 5 | LSB MSB 6 | 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 7 | + - - - - - - - - + - - - - - - - - + - - - - - - - - + - - - - - - - - + 8 | | version | en | type | len | 9 | + - - - - - - - - + - - - - - - - - + - - - - - - - - + - - - - - - - - + 10 | | Data... len bytes ... | 11 | + - - - - - - - - + - - - - - - - - + - - - - - - - - + - - - - - - - - + 12 | 13 | ## Syntax 14 | 15 | ## Control 16 | +-----------+ +-------------+ 17 | | AuthProxy | | GameServer | 18 | +-----------+ +-------------+ 19 | | | 20 | | Spawn And Establish TCP Connection | 21 | |-------------------------------------------------------->| 22 | | | 23 | | Control Message - Token + ID + Options / Configs | 24 | |-------------------------------------------------------->| 25 | | | 26 | 27 | ## Authentication 28 | 29 | +---------+ +-----------+ +-------------+ 30 | | Client | | AuthProxy | | GameServer | 31 | +---------+ +-----------+ +-------------+ 32 | | | | 33 | | Connect | | 34 | |---------------------------->| | 35 | | | | 36 | | Auth:ID | | 37 | |---------------------------->| | 38 | | | | 39 | | | Validate ID | 40 | | |------------ | 41 | | | | | 42 | | |<----------- | 43 | | | | 44 | | | Determine Server | 45 | | |----------------- | 46 | | | | | 47 | | |<---------------- | 48 | | | | 49 | | | Connect | 50 | | |----------------------------->| 51 | | | | 52 | | | Type:Client | 53 | | |----------------------------->| 54 | | | | 55 | | | ClientAuth:ID | 56 | | |----------------------------->| 57 | | | | 58 | | | Connected:GameDetails | 59 | | |<-----------------------------| 60 | | | | 61 | | Connected:GameDetails | | 62 | |<----------------------------| | 63 | | | | 64 | | | GameConfig | 65 | | |<-----------------------------| 66 | | | | 67 | | GameConfig | | 68 | |<----------------------------| | 69 | | | | 70 | 71 | ## 72 | -------------------------------------------------------------------------------- /pkg/pretty-log/log.go: -------------------------------------------------------------------------------- 1 | package prettylog 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "maps" 11 | "os" 12 | "slices" 13 | "strconv" 14 | "strings" 15 | "sync" 16 | 17 | "vim-arcade.theprimeagen.com/pkg/assert" 18 | ) 19 | 20 | const ( 21 | timeFormat = "[15:04:05.000]" 22 | 23 | reset = "\033[0m" 24 | 25 | black = 30 26 | red = 31 27 | green = 32 28 | yellow = 33 29 | blue = 34 30 | magenta = 35 31 | cyan = 36 32 | lightGray = 37 33 | darkGray = 90 34 | lightRed = 91 35 | lightGreen = 92 36 | lightYellow = 93 37 | lightBlue = 94 38 | lightMagenta = 95 39 | lightCyan = 96 40 | white = 97 41 | ) 42 | 43 | const LevelTrace = slog.LevelDebug - 4 44 | const LevelFatal = slog.LevelError + 4 45 | const ProcessKey = "process" 46 | const AreaKey = "area" 47 | 48 | var allColors = []int{ 49 | 31, 50 | 32, 51 | 33, 52 | 34, 53 | 35, 54 | 36, 55 | 37, 56 | 90, 57 | 91, 58 | 92, 59 | 93, 60 | 94, 61 | 95, 62 | 96, 63 | 97, 64 | } 65 | 66 | // TODO make this better 67 | func getProcessColor(process string) int { 68 | switch process { 69 | case "sim": 70 | return lightGreen 71 | case "DummyServer": 72 | return lightBlue 73 | } 74 | return lightMagenta 75 | } 76 | 77 | var areaColors = map[string]int{} 78 | var areaColorsIdx = 0 79 | 80 | func getAreaColor(area string) int { 81 | color, ok := areaColors[area] 82 | if !ok { 83 | color = allColors[areaColorsIdx%len(allColors)] 84 | areaColors[area] = color 85 | areaColorsIdx++ 86 | } 87 | 88 | return color 89 | } 90 | 91 | func isHandledKey(key string) bool { 92 | return key == ProcessKey || key == AreaKey || key == slog.LevelKey || 93 | key == slog.MessageKey || key == slog.TimeKey 94 | } 95 | 96 | func stringifyAttrs(attrs map[string]any) string { 97 | str := strings.Builder{} 98 | keys := slices.Sorted(maps.Keys(attrs)) 99 | 100 | for _, k := range keys { 101 | if isHandledKey(k) { 102 | continue 103 | } 104 | 105 | v := attrs[k] 106 | str.WriteString(k) 107 | str.WriteString("=") 108 | 109 | switch v.(type) { 110 | // TODO Go deep, go long, and figure out if there is a better way here 111 | case string, int, float32, float64, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: 112 | str.WriteString(fmt.Sprintf("%v", v)) 113 | default: 114 | str.WriteString(fmt.Sprintf("%+v", v)) 115 | } 116 | str.WriteString(" ") 117 | } 118 | return strings.TrimSpace(str.String()) 119 | } 120 | 121 | func Colorizer(colorCode int, v string) string { 122 | return fmt.Sprintf("\033[%sm%s%s", strconv.Itoa(colorCode), v, reset) 123 | } 124 | 125 | type Handler struct { 126 | handler slog.Handler 127 | replaceAttr func([]string, slog.Attr) slog.Attr 128 | buf *bytes.Buffer 129 | mutex *sync.Mutex 130 | writer io.Writer 131 | timestamp bool 132 | colorize bool 133 | outputEmptyAttrs bool 134 | } 135 | 136 | func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool { 137 | return h.handler.Enabled(ctx, level) 138 | } 139 | 140 | func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { 141 | return &Handler{handler: h.handler.WithAttrs(attrs), buf: h.buf, replaceAttr: h.replaceAttr, mutex: h.mutex, writer: h.writer, colorize: h.colorize} 142 | } 143 | 144 | func (h *Handler) WithGroup(name string) slog.Handler { 145 | return &Handler{handler: h.handler.WithGroup(name), buf: h.buf, replaceAttr: h.replaceAttr, mutex: h.mutex, writer: h.writer, colorize: h.colorize} 146 | } 147 | 148 | func (h *Handler) computeAttrs( 149 | ctx context.Context, 150 | r slog.Record, 151 | ) (map[string]any, error) { 152 | h.mutex.Lock() 153 | defer func() { 154 | h.buf.Reset() 155 | h.mutex.Unlock() 156 | }() 157 | if err := h.handler.Handle(ctx, r); err != nil { 158 | return nil, fmt.Errorf("error when calling inner handler's Handle: %w", err) 159 | } 160 | 161 | var attrs map[string]any 162 | err := json.Unmarshal(h.buf.Bytes(), &attrs) 163 | if err != nil { 164 | return nil, fmt.Errorf("error when unmarshaling inner handler's Handle result: %w", err) 165 | } 166 | return attrs, nil 167 | } 168 | 169 | func PrettyLine(data map[string]any, colorize func(code int, value string) string) (string, error) { 170 | 171 | process, ok := data[ProcessKey] 172 | assert.Assert(ok, "must provide process for my delicious pretty log") 173 | area, ok := data[AreaKey] 174 | assert.Assert(ok, "must provide area for my delicious pretty log") 175 | 176 | level := data["level"].(string) 177 | if level == "DEBUG-4" { 178 | level = "TRACE" 179 | } 180 | 181 | switch level { 182 | case "TRACE": 183 | fallthrough 184 | case "DEBUG": 185 | level = colorize(lightGray, level) 186 | case "INFO": 187 | level = colorize(cyan, level) 188 | case "WARN": 189 | level = colorize(lightYellow, level) 190 | case "ERROR": 191 | level = colorize(red, level) 192 | case "FATAL": 193 | level = colorize(magenta, level) 194 | default: 195 | assert.Never("unrecognized log level", "level", level) 196 | } 197 | 198 | msg := data["msg"].(string) 199 | msg = colorize(white, msg) 200 | 201 | var attrsAsBytes []byte 202 | var err error 203 | attrString := stringifyAttrs(data) 204 | if len(attrString) > 42 { 205 | attrsAsBytes, err = json.MarshalIndent(data, "", " ") 206 | if err != nil { 207 | return "", fmt.Errorf("error when marshaling attrs: %w", err) 208 | } 209 | } else { 210 | attrsAsBytes = []byte(attrString) 211 | } 212 | 213 | header := strings.Builder{} 214 | body := strings.Builder{} 215 | 216 | header.WriteString(colorize(getProcessColor(process.(string)), process.(string))) 217 | header.WriteString(":") 218 | header.WriteString(colorize(getAreaColor(area.(string)), area.(string))) 219 | header.WriteString(" ") 220 | 221 | body.WriteString(level) 222 | body.WriteString(" ") 223 | body.WriteString(msg) 224 | 225 | if len(attrsAsBytes) > 0 { 226 | body.WriteString(" ") 227 | body.WriteString(colorize(lightGray, string(attrsAsBytes))) 228 | } 229 | 230 | // disabled in de615d835b17974b3eda8c846ae51fe008663d83 231 | // i think there is a bug in ordering... 232 | // h.dedupedInnerPrint(header.String(), body.String()) 233 | return header.String() + body.String(), nil 234 | } 235 | 236 | func (h *Handler) Handle(ctx context.Context, r slog.Record) error { 237 | colorize := func(code int, value string) string { 238 | return value 239 | } 240 | 241 | if h.colorize { 242 | colorize = Colorizer 243 | } 244 | 245 | toPretty := map[string]any{} 246 | 247 | levelAttr := slog.Attr{ 248 | Key: slog.LevelKey, 249 | Value: slog.AnyValue(r.Level), 250 | } 251 | 252 | if h.replaceAttr != nil { 253 | levelAttr = h.replaceAttr([]string{}, levelAttr) 254 | } 255 | 256 | toPretty[slog.LevelKey] = levelAttr.Value.String() 257 | 258 | msgAttr := slog.Attr{ 259 | Key: slog.MessageKey, 260 | Value: slog.StringValue(r.Message), 261 | } 262 | 263 | if h.replaceAttr != nil { 264 | msgAttr = h.replaceAttr([]string{}, msgAttr) 265 | } 266 | 267 | attrs, err := h.computeAttrs(ctx, r) 268 | if err != nil { 269 | return err 270 | } 271 | 272 | toPretty[slog.LevelKey] = levelAttr.Value.String() 273 | toPretty[slog.MessageKey] = msgAttr.Value.String() 274 | toPretty[ProcessKey] = attrs[ProcessKey] 275 | toPretty[AreaKey] = attrs[AreaKey] 276 | 277 | for k, v := range attrs { 278 | toPretty[k] = v 279 | } 280 | 281 | str, err := PrettyLine(toPretty, colorize) 282 | if err != nil { 283 | return err 284 | } 285 | 286 | io.WriteString(h.writer, str) 287 | io.WriteString(h.writer, "\n") 288 | 289 | return nil 290 | } 291 | 292 | func suppressDefaults( 293 | next func([]string, slog.Attr) slog.Attr, 294 | ) func([]string, slog.Attr) slog.Attr { 295 | return func(groups []string, a slog.Attr) slog.Attr { 296 | if a.Key == slog.TimeKey || 297 | a.Key == slog.LevelKey || 298 | a.Key == slog.MessageKey { 299 | return slog.Attr{} 300 | } 301 | if next == nil { 302 | return a 303 | } 304 | return next(groups, a) 305 | } 306 | } 307 | 308 | func New(handlerOptions *slog.HandlerOptions, options ...Option) *Handler { 309 | if handlerOptions == nil { 310 | handlerOptions = &slog.HandlerOptions{} 311 | } 312 | 313 | buf := &bytes.Buffer{} 314 | handler := &Handler{ 315 | buf: buf, 316 | timestamp: false, 317 | handler: slog.NewJSONHandler(buf, &slog.HandlerOptions{ 318 | Level: handlerOptions.Level, 319 | AddSource: handlerOptions.AddSource, 320 | ReplaceAttr: suppressDefaults(handlerOptions.ReplaceAttr), 321 | }), 322 | replaceAttr: handlerOptions.ReplaceAttr, 323 | mutex: &sync.Mutex{}, 324 | } 325 | 326 | for _, opt := range options { 327 | opt(handler) 328 | } 329 | 330 | return handler 331 | } 332 | 333 | func NewHandler(opts *slog.HandlerOptions, params PrettyLoggerParams, options ...Option) *Handler { 334 | options = append([]Option{ 335 | WithDestinationWriter(params.Out), 336 | WithColor(), 337 | WithOutputEmptyAttrs(), 338 | }, options...) 339 | return New(opts, options...) 340 | } 341 | 342 | type Option func(h *Handler) 343 | 344 | func WithTimestamp() Option { 345 | return func(h *Handler) { 346 | h.timestamp = true 347 | } 348 | } 349 | 350 | func WithDestinationWriter(writer io.Writer) Option { 351 | return func(h *Handler) { 352 | h.writer = writer 353 | } 354 | } 355 | 356 | func WithColor() Option { 357 | return func(h *Handler) { 358 | h.colorize = true 359 | } 360 | } 361 | 362 | func WithoutColor() Option { 363 | return func(h *Handler) { 364 | h.colorize = false 365 | } 366 | } 367 | 368 | func WithOutputEmptyAttrs() Option { 369 | return func(h *Handler) { 370 | h.outputEmptyAttrs = true 371 | } 372 | } 373 | 374 | type PrettyLoggerParams struct { 375 | Out io.Writer 376 | Level slog.Level 377 | } 378 | 379 | func NewParams(out io.Writer) PrettyLoggerParams { 380 | return PrettyLoggerParams{ 381 | Level: LevelTrace, 382 | 383 | Out: out, 384 | } 385 | } 386 | 387 | func SetProgramLevelPrettyLogger(params PrettyLoggerParams) *slog.Logger { 388 | if os.Getenv("NO_PRETTY_LOGGER") != "" { 389 | return slog.Default() 390 | } 391 | 392 | prettyHandler := NewHandler(&slog.HandlerOptions{ 393 | Level: params.Level, 394 | AddSource: false, 395 | ReplaceAttr: nil, 396 | }, params) 397 | logger := slog.New(prettyHandler) 398 | slog.SetDefault(logger) 399 | return logger 400 | } 401 | 402 | func CreateLoggerSink() *os.File { 403 | var f *os.File 404 | var err error 405 | 406 | debugLog := os.Getenv("DEBUG_LOG") 407 | if debugLog == "" { 408 | f = os.Stderr 409 | } else { 410 | f, err = os.OpenFile(debugLog, os.O_RDWR|os.O_CREATE, 0644) 411 | assert.NoError(err, "unable to create temporary file") 412 | } 413 | 414 | return f 415 | } 416 | 417 | func CreateLoggerFromEnv(out *os.File) *slog.Logger { 418 | if out == nil { 419 | out = CreateLoggerSink() 420 | } 421 | 422 | if os.Getenv("DEBUG_TYPE") == "pretty" { 423 | return SetProgramLevelPrettyLogger(NewParams(out)) 424 | } 425 | 426 | logger := slog.New(slog.NewJSONHandler(out, nil)) 427 | slog.SetDefault(logger) 428 | return logger 429 | } 430 | 431 | func Trace(log *slog.Logger, msg string, data ...any) { 432 | log.Log(context.Background(), LevelTrace, msg, data...) 433 | } 434 | -------------------------------------------------------------------------------- /pkg/quick-math/AABB.go: -------------------------------------------------------------------------------- 1 | package quickmath 2 | 3 | type AABB struct { 4 | Min, Max Vec2 5 | } 6 | 7 | func (a AABB) Intersect(b AABB) bool { 8 | return a.Min.X < b.Max.X && a.Max.X > b.Min.X && 9 | a.Min.Y < b.Max.Y && a.Max.Y > b.Min.Y 10 | } 11 | 12 | -------------------------------------------------------------------------------- /pkg/quick-math/AABB_test.go: -------------------------------------------------------------------------------- 1 | package quickmath_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | quickmath "vim-arcade.theprimeagen.com/pkg/quick-math" 8 | ) 9 | 10 | type Vec2 = quickmath.Vec2 11 | type AABB = quickmath.AABB 12 | 13 | func TestAABBIntersect(t *testing.T) { 14 | // Positive Cases (Intersections) 15 | t.Run("Basic intersection", func(t *testing.T) { 16 | a := AABB{Min: Vec2{X: 0, Y: 0}, Max: Vec2{X: 5, Y: 5}} 17 | b := AABB{Min: Vec2{X: 3, Y: 3}, Max: Vec2{X: 7, Y: 7}} 18 | require.True(t, a.Intersect(b), "Expected AABBs to intersect") 19 | require.True(t, b.Intersect(a), "Expected AABBs to intersect") 20 | }) 21 | 22 | t.Run("Exact overlap", func(t *testing.T) { 23 | a := AABB{Min: Vec2{X: 0, Y: 0}, Max: Vec2{X: 5, Y: 5}} 24 | b := AABB{Min: Vec2{X: 0, Y: 0}, Max: Vec2{X: 5, Y: 5}} 25 | require.True(t, a.Intersect(b), "Expected AABBs to intersect") 26 | }) 27 | 28 | t.Run("One inside the other", func(t *testing.T) { 29 | a := AABB{Min: Vec2{X: 0, Y: 0}, Max: Vec2{X: 10, Y: 10}} 30 | b := AABB{Min: Vec2{X: 3, Y: 3}, Max: Vec2{X: 7, Y: 7}} 31 | require.True(t, a.Intersect(b), "Expected AABBs to intersect") 32 | require.True(t, b.Intersect(a), "Expected AABBs to intersect") 33 | }) 34 | 35 | // Negative Cases (No Intersection) 36 | t.Run("No intersection - completely separate UD", func(t *testing.T) { 37 | a := AABB{Min: Vec2{X: 0, Y: 0}, Max: Vec2{X: 5, Y: 5}} 38 | b := AABB{Min: Vec2{X: 0, Y: 6}, Max: Vec2{X: 5, Y: 10}} 39 | require.False(t, a.Intersect(b), "Expected AABBs to not intersect") 40 | require.False(t, b.Intersect(a), "Expected AABBs to not intersect") 41 | }) 42 | 43 | t.Run("No intersection - completely separate LR", func(t *testing.T) { 44 | a := AABB{Min: Vec2{X: 0, Y: 0}, Max: Vec2{X: 5, Y: 5}} 45 | b := AABB{Min: Vec2{X: 5, Y: 0}, Max: Vec2{X: 10, Y: 5}} 46 | require.False(t, a.Intersect(b), "Expected AABBs to not intersect") 47 | require.False(t, b.Intersect(a), "Expected AABBs to not intersect") 48 | }) 49 | 50 | t.Run("Touching edges - no intersection LR", func(t *testing.T) { 51 | a := AABB{Min: Vec2{X: 0, Y: 0}, Max: Vec2{X: 5, Y: 5}} 52 | b := AABB{Min: Vec2{X: 5, Y: 0}, Max: Vec2{X: 10, Y: 5}} 53 | require.False(t, a.Intersect(b), "Expected AABBs to not intersect") 54 | require.False(t, b.Intersect(a), "Expected AABBs to not intersect") 55 | }) 56 | 57 | t.Run("Touching edges - no intersection UD", func(t *testing.T) { 58 | a := AABB{Min: Vec2{X: 0, Y: 5}, Max: Vec2{X: 5, Y: 10}} 59 | b := AABB{Min: Vec2{X: 0, Y: 0}, Max: Vec2{X: 5, Y: 5}} 60 | require.False(t, a.Intersect(b), "Expected AABBs to not intersect") 61 | require.False(t, b.Intersect(a), "Expected AABBs to not intersect") 62 | }) 63 | 64 | t.Run("Touching corners - no intersection", func(t *testing.T) { 65 | a := AABB{Min: Vec2{X: 0, Y: 0}, Max: Vec2{X: 5, Y: 5}} 66 | b := AABB{Min: Vec2{X: 5, Y: 5}, Max: Vec2{X: 10, Y: 10}} 67 | require.False(t, a.Intersect(b), "Expected AABBs to not intersect") 68 | require.False(t, b.Intersect(a), "Expected AABBs to not intersect") 69 | }) 70 | } 71 | 72 | 73 | -------------------------------------------------------------------------------- /pkg/quick-math/Vec.go: -------------------------------------------------------------------------------- 1 | package quickmath 2 | 3 | import "math" 4 | 5 | type Vec2 struct { 6 | X, Y float64 7 | } 8 | 9 | func NewVec2(x, y float64) Vec2 { 10 | return Vec2{X: x, Y: y} 11 | } 12 | 13 | func (v Vec2) Add(other Vec2) Vec2 { 14 | return Vec2{X: v.X + other.X, Y: v.Y + other.Y} 15 | } 16 | 17 | func (v Vec2) Mul(other Vec2) Vec2 { 18 | return Vec2{X: v.X * other.X, Y: v.Y * other.Y} 19 | } 20 | 21 | func (v Vec2) Scale(scalar float64) Vec2 { 22 | return Vec2{X: v.X * scalar, Y: v.Y * scalar} 23 | } 24 | 25 | func (v Vec2) Sub(other Vec2) Vec2 { 26 | return Vec2{X: v.X - other.X, Y: v.Y - other.Y} 27 | } 28 | 29 | func (v Vec2) Len() float64 { 30 | return math.Sqrt(v.LenSq()) 31 | } 32 | 33 | func (v Vec2) LenSq() float64 { 34 | return v.X*v.X + v.Y*v.Y 35 | } 36 | 37 | func (v Vec2) Norm() Vec2 { 38 | length := v.Len() 39 | if length == 0 { 40 | return Vec2{X: 0, Y: 0} 41 | } 42 | return Vec2{X: v.X / length, Y: v.Y / length} 43 | } 44 | 45 | -------------------------------------------------------------------------------- /pkg/quick-math/Vec_test.go: -------------------------------------------------------------------------------- 1 | package quickmath_test 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | "vim-arcade.theprimeagen.com/pkg/quick-math" 9 | ) 10 | 11 | var Vec = quickmath.NewVec2 12 | func TestVec2Init(t *testing.T) { 13 | vec := Vec(1.0, 2.0) 14 | require.Equal(t, vec, Vec(1.0, 2.0)) 15 | } 16 | 17 | func TestVec2Operations(t *testing.T) { 18 | vec := Vec(1.0, 2.0) 19 | vecLen := math.Sqrt(1 + 4) 20 | require.Equal(t, vec.Add(Vec(68.0, 67.0)), Vec(69, 69)) 21 | require.Equal(t, vec.Mul(Vec(3.5, 4.345)), Vec(3.5, 8.69)) 22 | require.Equal(t, vec.Scale(4), Vec(4, 8)) 23 | require.Equal(t, vec.Sub(Vec(4, 3.5)), Vec(-3.0, -1.5)) 24 | require.Equal(t, vec.Len(), vecLen) 25 | require.Equal(t, Vec(0, 0).Len(), 0.0) 26 | require.Equal(t, Vec(0, 0).Norm(), Vec(0, 0)) 27 | require.Equal(t, vec.LenSq(), 5.0) 28 | 29 | require.Equal(t, vec.Norm(), Vec(1.0 / vecLen, 2.0 / vecLen)) 30 | } 31 | 32 | 33 | -------------------------------------------------------------------------------- /pkg/server-management/flyio.go: -------------------------------------------------------------------------------- 1 | package servermanagement 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | ) 11 | 12 | var BASE_URL = "https://api.machines.dev" 13 | 14 | func machineCreateUrl() string { 15 | return fmt.Sprintf("%s/v1/apps/vim-arcade/machines", BASE_URL) 16 | } 17 | 18 | func machineStart(machineId string) string { 19 | return fmt.Sprintf("%s/start", machine(machineId)) 20 | } 21 | 22 | func machineStop(machineId string) string { 23 | return fmt.Sprintf("%s/stop", machine(machineId)) 24 | } 25 | 26 | func machineDestroy(machineId string) string { 27 | return fmt.Sprintf("%s?force=true", machine(machineId)) 28 | } 29 | 30 | func machine(machineId string) string { 31 | return fmt.Sprintf("%s/v1/apps/vim-arcade/machines/%s", BASE_URL, machineId) 32 | } 33 | 34 | //{"id":"1852414f4125d8","name":"vim-arcade","state":"created","region":"den","instance_id":"01J6ZE56EA12S649SSKRK71PF6","private_ip":"fdaa:3:c60a:a7b:5:5607:a0b9:2","config":{"env":{"APP_ENV":"production"},"init":{},"guest":{"cpu_kind":"shared","cpus":1,"memory_mb":256},"services":[{"protocol":"tcp","internal_port":8080,"ports":[{"port":80,"handlers":["http"]}],"force_instance_key":null}],"image":"registry.fly.io/vim-arcade:deployment-01J6XBCR5F95VZAH6REWNP2XBC"},"incomplete_config":null,"image_ref":{"registry":"registry.fly.io","repository":"vim-arcade","tag":"deployment-01J6XBCR5F95VZAH6REWNP2XBC","digest":"sha256:ac31956327e300c624741d92b6537f766890d239f6ef8e44fc20edd1c672f94f","labels":null},"created_at":"2024-09-04T21:13:27Z","updated_at":"2024-09-04T21:13:27Z","events":[{"id":"01J6ZE56FARDMV304HFVVAT8PK","type":"launch","status":"created","source":"user","timestamp":1725484407274}],"host_status":"ok"} 35 | type MachineCreateResponse struct { 36 | Id string `json:"id"` 37 | InstanceID string `json:"instance_id"` 38 | } 39 | 40 | func (m *MachineCreateResponse) String() string { 41 | return fmt.Sprintf("Id: %s -- InstanceID: %s", m.Id, m.InstanceID) 42 | } 43 | 44 | func createMachine() (*MachineCreateResponse, error) { 45 | body := []byte(`{ 46 | "config": { 47 | "image": "registry.fly.io/vim-arcade:deployment-01J6XBCR5F95VZAH6REWNP2XBC", 48 | "env": { 49 | "APP_ENV": "production" 50 | }, 51 | "services": [ 52 | { 53 | "ports": [ 54 | { 55 | "port": 443, 56 | "handlers": [ 57 | "tls", 58 | "http" 59 | ] 60 | }, 61 | { 62 | "port": 80, 63 | "handlers": [ 64 | "http" 65 | ] 66 | } 67 | ], 68 | "protocol": "tcp", 69 | "internal_port": 8080 70 | } 71 | ] 72 | } 73 | }`) 74 | 75 | r, err := http.NewRequest("POST", machineCreateUrl(), bytes.NewBuffer(body)) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | r.Header.Add("Content-Type", "application/json") 81 | r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", os.Getenv("FLY_IO_ORG_TOKEN"))) 82 | 83 | client := &http.Client{} 84 | res, err := client.Do(r) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | defer res.Body.Close() 90 | 91 | machineResponse := MachineCreateResponse{} 92 | b, err := io.ReadAll(res.Body) 93 | if err != nil { 94 | return nil, err 95 | } 96 | fmt.Printf("Machine: %s -- %d\n", string(b), res.StatusCode) 97 | err = json.Unmarshal(b, &machineResponse) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | return &machineResponse, nil 103 | } 104 | 105 | func getMachine(machineId string) (string, error) { 106 | r, err := http.NewRequest("GET", machine(machineId), bytes.NewBuffer([]byte{})) 107 | if err != nil { 108 | return "", err 109 | } 110 | 111 | r.Header.Add("Content-Type", "application/json") 112 | r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", os.Getenv("FLY_IO_ORG_TOKEN"))) 113 | 114 | client := &http.Client{} 115 | res, err := client.Do(r) 116 | if err != nil { 117 | return "", err 118 | } 119 | 120 | defer res.Body.Close() 121 | 122 | b, err := io.ReadAll(res.Body) 123 | if err != nil { 124 | return "", err; 125 | } 126 | 127 | return string(b), err 128 | } 129 | 130 | func stopMachine(machineId string) (string, error) { 131 | r, err := http.NewRequest("POST", machineStop(machineId), bytes.NewBuffer([]byte{})) 132 | if err != nil { 133 | return "", err 134 | } 135 | 136 | r.Header.Add("Content-Type", "application/json") 137 | r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", os.Getenv("FLY_IO_ORG_TOKEN"))) 138 | 139 | client := &http.Client{} 140 | res, err := client.Do(r) 141 | if err != nil { 142 | return "", err 143 | } 144 | 145 | defer res.Body.Close() 146 | 147 | b, err := io.ReadAll(res.Body) 148 | if err != nil { 149 | return "", err; 150 | } 151 | 152 | return string(b), err 153 | } 154 | 155 | func startMachine(machineId string) (string, error) { 156 | r, err := http.NewRequest("POST", machineStart(machineId), bytes.NewBuffer([]byte{})) 157 | if err != nil { 158 | return "", err 159 | } 160 | 161 | r.Header.Add("Content-Type", "application/json") 162 | r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", os.Getenv("FLY_IO_ORG_TOKEN"))) 163 | 164 | client := &http.Client{} 165 | res, err := client.Do(r) 166 | if err != nil { 167 | return "", err 168 | } 169 | 170 | defer res.Body.Close() 171 | 172 | b, err := io.ReadAll(res.Body) 173 | if err != nil { 174 | return "", err; 175 | } 176 | 177 | return string(b), err 178 | } 179 | 180 | func destroyMachine(machineId string) (string, error) { 181 | r, err := http.NewRequest("DELETE", machineDestroy(machineId), bytes.NewBuffer([]byte{})) 182 | if err != nil { 183 | return "", err 184 | } 185 | 186 | r.Header.Add("Content-Type", "application/json") 187 | r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", os.Getenv("FLY_IO_ORG_TOKEN"))) 188 | 189 | client := &http.Client{} 190 | res, err := client.Do(r) 191 | if err != nil { 192 | return "", err 193 | } 194 | 195 | defer res.Body.Close() 196 | 197 | b, err := io.ReadAll(res.Body) 198 | if err != nil { 199 | return "", err; 200 | } 201 | 202 | return string(b), err 203 | } 204 | 205 | -------------------------------------------------------------------------------- /pkg/server-management/local.go: -------------------------------------------------------------------------------- 1 | package servermanagement 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "vim-arcade.theprimeagen.com/pkg/assert" 12 | "vim-arcade.theprimeagen.com/pkg/cmd" 13 | gameserverstats "vim-arcade.theprimeagen.com/pkg/game-server-stats" 14 | ) 15 | 16 | type LocalServers struct { 17 | logger *slog.Logger 18 | stats gameserverstats.GSSRetriever 19 | params ServerParams 20 | servers []*cmd.Cmder 21 | 22 | load float32 23 | connections float32 24 | 25 | lastTimeNoConnections bool 26 | } 27 | 28 | func getEnvVars() []string { 29 | return []string{ 30 | fmt.Sprintf("GOPATH=%s", os.Getenv("GOPATH")), 31 | fmt.Sprintf("SQLITE=%s", os.Getenv("SQLITE")), 32 | fmt.Sprintf("DEBUG_TYPE=%s", os.Getenv("DEBUG_TYPE")), 33 | } 34 | } 35 | 36 | func NewLocalServers(stats gameserverstats.GSSRetriever, params ServerParams) LocalServers { 37 | return LocalServers{ 38 | stats: stats, 39 | params: params, 40 | servers: []*cmd.Cmder{}, 41 | logger: slog.Default().With("area", "LocalServers"), 42 | lastTimeNoConnections: false, 43 | } 44 | } 45 | 46 | func (l *LocalServers) GetBestServer() (string, error) { 47 | servers := l.stats.GetServersByUtilization(float64(l.params.MaxLoad)) 48 | 49 | if len(servers) == 0 { 50 | l.logger.Info("GetBestServer no servers found") 51 | return "", NoBestServer 52 | } 53 | 54 | l.logger.Info("GetBestServer server returned", "server", servers[0].String()) 55 | return servers[0].Id, nil 56 | } 57 | 58 | var id = 0 59 | 60 | func (l *LocalServers) CreateNewServer(ctx context.Context) (string, error) { 61 | dummyServer := os.Getenv("GAME_SERVER") 62 | if dummyServer == "" { 63 | dummyServer = "./cmd/api-server/main.go" 64 | } 65 | outId := id 66 | // TODO i bet there is a better way of doing this... 67 | // i just don't know other than straight passthrough? 68 | // i feel like i need more intelligent passing of logs from inner to outer 69 | cmdr := cmd.NewCmder("go", ctx). 70 | AddVArgv([]string{"run", dummyServer}). 71 | WithOutFn(func(b []byte) (int, error) { 72 | fmt.Fprintf(os.Stdout, "%s", string(b)) 73 | return len(b), nil 74 | }). 75 | WithErrFn(func(b []byte) (int, error) { 76 | fmt.Fprintf(os.Stderr, "%s", string(b)) 77 | return len(b), nil 78 | }) 79 | 80 | id++ 81 | 82 | go func() { 83 | vars := getEnvVars() 84 | vars = append(vars, 85 | fmt.Sprintf("ID=%d", outId), 86 | 87 | // subprocesses should not have the log file as it will cause odd 88 | // log file truncation 89 | fmt.Sprintf("DEBUG_LOG="), 90 | ) 91 | 92 | err := cmdr.Run(vars) 93 | cancelled := false 94 | select { 95 | case <-ctx.Done(): 96 | cancelled = true 97 | default: 98 | } 99 | 100 | if cancelled { 101 | l.logger.Error("cmdr context killed") 102 | } else if !cancelled && err != nil { 103 | l.logger.Error("unable to run cmdr", "err", err) 104 | } 105 | 106 | // TODO the database checking to prove that this commander has closed 107 | // properly 108 | done := false 109 | select { 110 | case <-ctx.Done(): 111 | done = true 112 | default: 113 | config := l.stats.GetById(fmt.Sprintf("%d", outId)) 114 | if config != nil { 115 | done = config.State == gameserverstats.GSStateClosed 116 | } 117 | } 118 | 119 | if !done { 120 | assert.Never("cmdr has closed unexpectedly", "id", outId) 121 | } 122 | }() 123 | 124 | l.servers = append(l.servers, cmdr) 125 | return fmt.Sprintf("%d", outId), nil 126 | } 127 | 128 | // TODO Add timeout...? 129 | func (l *LocalServers) WaitForReady(ctx context.Context, id string) error { 130 | for { 131 | time.Sleep(time.Millisecond * 50) 132 | 133 | gs := l.stats.GetById(id) 134 | l.logger.Info("WaitForReady", "id", id, "gs", gs) 135 | if gs != nil { 136 | if gs.State == gameserverstats.GSStateReady { 137 | return nil 138 | } else if gs.State == gameserverstats.GSStateClosed { 139 | // TODO Add closed error 140 | return nil 141 | } 142 | } 143 | } 144 | } 145 | 146 | func (l *LocalServers) GetConnectionString(id string) (string, error) { 147 | gs := l.stats.GetById(id) 148 | if gs == nil { 149 | // TODO Handle DNE error 150 | return "", nil 151 | } 152 | return fmt.Sprintf("%s:%d", gs.Host, gs.Port), nil 153 | } 154 | 155 | func (l *LocalServers) refresh(ctx context.Context) { 156 | } 157 | 158 | func (l *LocalServers) Run(ctx context.Context) { 159 | 160 | // TODO(v1) make this configurable 161 | timer := time.NewTicker(time.Second * 30) 162 | defer timer.Stop() 163 | 164 | outer: 165 | for { 166 | select { 167 | case <-ctx.Done(): 168 | break outer 169 | case <-timer.C: 170 | l.refresh(ctx) 171 | } 172 | } 173 | } 174 | 175 | func (l *LocalServers) Close() { 176 | for _, c := range l.servers { 177 | c.Close() 178 | } 179 | } 180 | 181 | func (l *LocalServers) Ready() { 182 | // TODO i should maybe create one server IF there are no servers 183 | } 184 | 185 | func (l *LocalServers) String() string { 186 | servers := []string{} 187 | gameServers := l.stats.GetServersByUtilization(1500) 188 | for _, gs := range gameServers { 189 | servers = append(servers, gs.String()) 190 | } 191 | return strings.Join(servers, "\n") 192 | } 193 | -------------------------------------------------------------------------------- /pkg/server-management/server.go: -------------------------------------------------------------------------------- 1 | package servermanagement 2 | 3 | import "errors" 4 | 5 | var NoBestServer = errors.New("no best server found") 6 | 7 | type ServerParams struct { 8 | MaxLoad float32 9 | } 10 | -------------------------------------------------------------------------------- /pkg/utils/context.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | ) 8 | 9 | const DateTimeFormatForSQLite = "2006-01-02 15:04:05" 10 | 11 | type ContextReader struct { 12 | Err chan error 13 | Out chan []byte 14 | ctx context.Context 15 | } 16 | 17 | func NewContextReader(ctx context.Context) ContextReader { 18 | return ContextReader{ 19 | Err: make(chan error, 1), 20 | Out: make(chan []byte, 10), 21 | ctx: ctx, 22 | } 23 | } 24 | 25 | func internalRead(in io.Reader, out chan []byte, err chan error) { 26 | data := make([]byte, 1024, 1024) 27 | for { 28 | n, e := in.Read(data) 29 | if e != nil { 30 | if !errors.Is(e, io.EOF) { 31 | err <- e 32 | } 33 | break 34 | } 35 | 36 | o := make([]byte, n, n) 37 | copy(o, data[0:n]) 38 | 39 | out <- o 40 | } 41 | } 42 | 43 | func (c *ContextReader) Read(in io.Reader) { 44 | go func() { 45 | ctx, cancel := context.WithCancel(c.ctx) 46 | defer func() { 47 | close(c.Err) 48 | close(c.Out) 49 | }() 50 | 51 | err := make(chan error, 1) 52 | go internalRead(in, c.Out, err) 53 | go func() { 54 | e := <-err 55 | c.Err <- e 56 | cancel() 57 | }() 58 | <-ctx.Done() 59 | }() 60 | } 61 | 62 | -------------------------------------------------------------------------------- /pkg/utils/pretty.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func PrettyPrintBytes(data []byte, printMax int) string { 9 | out := []string{} 10 | printOut := min(len(data), 16) 11 | ellipse := len(data) > printOut 12 | 13 | for i := range printOut { 14 | out = append(out, fmt.Sprintf("%02x", data[i])) 15 | } 16 | 17 | if ellipse { 18 | out = append(out, "...") 19 | } 20 | 21 | return strings.Join(out, " ") 22 | } 23 | 24 | -------------------------------------------------------------------------------- /pkg/utils/writer.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | func WriteAll(data []byte, writer io.Writer) error { 8 | wrote := 0 9 | for { 10 | if wrote == len(data) { 11 | break 12 | } 13 | 14 | n, err := writer.Write(data) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | wrote += n 20 | } 21 | 22 | return nil 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use tokio::{net::{TcpListener, TcpStream}, io::{AsyncReadExt}}; 3 | use clap::Parser; 4 | 5 | #[derive(Parser, Debug, Clone)] 6 | #[command(version, about, long_about = None)] 7 | struct Args { 8 | /// Name of the person to greet 9 | #[arg(short, long)] 10 | port: u16 11 | } 12 | 13 | impl ToString for Args { 14 | // Required method 15 | fn to_string(&self) -> String { 16 | return format!("127.0.0.1:{}", self.port); 17 | } 18 | } 19 | 20 | async fn pipe_conn(mut tcp: TcpStream, args: &Args) -> Result<()> { 21 | println!("connecting to {}", args.to_string()); 22 | let mut to_conn = TcpStream::connect(args.to_string()).await?; 23 | println!("connection established"); 24 | //let (mut to_read, mut to_write) = to_conn.into_split(); 25 | //let (mut from_read, mut from_write) = tcp.into_split(); 26 | 27 | _ = tokio::io::copy_bidirectional(&mut tcp, &mut to_conn).await; 28 | 29 | return Ok(()) 30 | } 31 | 32 | #[tokio::main] 33 | async fn main() -> Result<()> { 34 | let args: &'static Args = Box::leak(Box::new(Args::parse())); 35 | let listener = TcpListener::bind("127.0.0.1:7878").await?; 36 | 37 | while let Ok((tcp, _)) = listener.accept().await { 38 | println!("i got a connection"); 39 | tokio::spawn(pipe_conn(tcp, args)); 40 | } 41 | 42 | return Ok(()) 43 | } 44 | -------------------------------------------------------------------------------- /td.Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GO_VERSION=1 2 | FROM golang:1.23.0-bookworm as builder 3 | 4 | WORKDIR /usr/src/app 5 | COPY go.mod /usr/src/app 6 | RUN go mod download && go mod verify 7 | COPY cmd cmd 8 | COPY pkg pkg 9 | RUN go build -v -o /run-app /usr/src/app/cmd/td/main.go 10 | 11 | FROM debian:bookworm 12 | 13 | COPY --from=builder /run-app /usr/local/bin/ 14 | CMD ["run-app"] 15 | 16 | --------------------------------------------------------------------------------