diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8b0d9e323..1f2fc99b3 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,8 +4,6 @@ on: push: branches: - main - tags: - - 'v*' env: REGISTRY: ghcr.io diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee56e460e..ad36a3181 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,13 @@ on: permissions: contents: write + packages: write + attestations: write + id-token: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} jobs: build: @@ -56,9 +63,49 @@ jobs: path: erupe-${{ matrix.os_name }}.zip retention-days: 1 + docker: + name: Build and Push Docker Image + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v2 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + release: name: Create Release - needs: build + needs: [build, docker] runs-on: ubuntu-latest steps: @@ -76,6 +123,15 @@ jobs: uses: softprops/action-gh-release@v2 with: generate_release_notes: true + body: | + ## Docker Image + + This release is also available as a Docker image: + + ```bash + docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + ``` + append_body: true files: | artifacts/Linux-amd64/erupe-Linux-amd64.zip artifacts/Windows-amd64/erupe-Windows-amd64.zip diff --git a/common/db/adapter.go b/common/db/adapter.go new file mode 100644 index 000000000..de71b85bf --- /dev/null +++ b/common/db/adapter.go @@ -0,0 +1,68 @@ +// Package db provides a database adapter that transparently translates +// PostgreSQL-style SQL to the active driver's dialect. +// +// When the driver is "sqlite", queries are rewritten on the fly: +// - $1, $2, ... → ?, ?, ... +// - now() → CURRENT_TIMESTAMP +// - ::type casts → removed +// - ILIKE → LIKE (SQLite LIKE is case-insensitive for ASCII) +package db + +import ( + "regexp" + "strings" + + "github.com/jmoiron/sqlx" +) + +// IsSQLite reports whether the given sqlx.DB is backed by a SQLite driver. +func IsSQLite(db *sqlx.DB) bool { + return db.DriverName() == "sqlite" || db.DriverName() == "sqlite3" +} + +// Adapt rewrites a PostgreSQL query for the active driver. +// For Postgres it's a no-op. For SQLite it translates placeholders and +// Postgres-specific syntax. +func Adapt(db *sqlx.DB, query string) string { + if !IsSQLite(db) { + return query + } + return AdaptSQL(query) +} + +// castRe matches Postgres type casts like ::int, ::text, ::timestamptz, +// ::character varying, etc. +// castRe matches Postgres type casts: ::int, ::text, ::timestamptz, +// ::character varying(N), etc. The space is allowed only when followed +// by a word char (e.g. "character varying") to avoid eating trailing spaces. +var castRe = regexp.MustCompile(`::[a-zA-Z_]\w*(?:\s+\w+)*(?:\([^)]*\))?`) + +// dollarParamRe matches Postgres-style positional parameters: $1, $2, etc. +var dollarParamRe = regexp.MustCompile(`\$\d+`) + +// AdaptSQL translates a PostgreSQL query to SQLite-compatible SQL. +// Exported so it can be tested without a real DB connection. +func AdaptSQL(query string) string { + // 1. Replace now() with CURRENT_TIMESTAMP + query = strings.ReplaceAll(query, "now()", "CURRENT_TIMESTAMP") + query = strings.ReplaceAll(query, "NOW()", "CURRENT_TIMESTAMP") + + // 2. Strip Postgres type casts (::int, ::text, ::timestamptz, etc.) + query = castRe.ReplaceAllString(query, "") + + // 3. ILIKE → LIKE (SQLite LIKE is case-insensitive for ASCII by default) + query = strings.ReplaceAll(query, " ILIKE ", " LIKE ") + query = strings.ReplaceAll(query, " ilike ", " LIKE ") + + // 4. Strip "public." schema prefix (SQLite has no schemas) + query = strings.ReplaceAll(query, "public.", "") + + // 5. TRUNCATE → DELETE FROM (SQLite has no TRUNCATE) + query = strings.ReplaceAll(query, "TRUNCATE ", "DELETE FROM ") + query = strings.ReplaceAll(query, "truncate ", "DELETE FROM ") + + // 6. Replace $1,$2,... → ?,?,... + query = dollarParamRe.ReplaceAllString(query, "?") + + return query +} diff --git a/common/db/adapter_test.go b/common/db/adapter_test.go new file mode 100644 index 000000000..08ff6f402 --- /dev/null +++ b/common/db/adapter_test.go @@ -0,0 +1,83 @@ +package db + +import ( + "testing" +) + +func TestAdaptSQL(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "placeholder rebind", + input: "SELECT * FROM users WHERE id=$1 AND name=$2", + want: "SELECT * FROM users WHERE id=? AND name=?", + }, + { + name: "now() replacement", + input: "UPDATE characters SET guild_post_checked=now() WHERE id=$1", + want: "UPDATE characters SET guild_post_checked=CURRENT_TIMESTAMP WHERE id=?", + }, + { + name: "type cast removal", + input: "UPDATE users SET frontier_points=frontier_points::int - $1 WHERE id=$2 RETURNING frontier_points", + want: "UPDATE users SET frontier_points=frontier_points - ? WHERE id=? RETURNING frontier_points", + }, + { + name: "text cast removal", + input: "SELECT COALESCE(friends, ''::text) FROM characters WHERE id=$1", + want: "SELECT COALESCE(friends, '') FROM characters WHERE id=?", + }, + { + name: "timestamptz cast removal", + input: "SELECT COALESCE(created_at, '2000-01-01'::timestamptz) FROM guilds WHERE id=$1", + want: "SELECT COALESCE(created_at, '2000-01-01') FROM guilds WHERE id=?", + }, + { + name: "ILIKE to LIKE", + input: "SELECT * FROM characters WHERE name ILIKE $1", + want: "SELECT * FROM characters WHERE name LIKE ?", + }, + { + name: "character varying cast", + input: "DEFAULT ''::character varying", + want: "DEFAULT ''", + }, + { + name: "no changes for standard SQL", + input: "SELECT COUNT(*) FROM users", + want: "SELECT COUNT(*) FROM users", + }, + { + name: "NOW uppercase", + input: "INSERT INTO events (start_time) VALUES (NOW())", + want: "INSERT INTO events (start_time) VALUES (CURRENT_TIMESTAMP)", + }, + { + name: "multi-digit params", + input: "INSERT INTO t (a,b,c) VALUES ($1,$2,$10)", + want: "INSERT INTO t (a,b,c) VALUES (?,?,?)", + }, + { + name: "public schema prefix", + input: "INSERT INTO public.distributions_accepted VALUES ($1, $2)", + want: "INSERT INTO distributions_accepted VALUES (?, ?)", + }, + { + name: "TRUNCATE to DELETE FROM", + input: "TRUNCATE public.cafebonus;", + want: "DELETE FROM cafebonus;", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := AdaptSQL(tc.input) + if got != tc.want { + t.Errorf("\ngot: %s\nwant: %s", got, tc.want) + } + }) + } +} diff --git a/common/db/db.go b/common/db/db.go new file mode 100644 index 000000000..0a5171d30 --- /dev/null +++ b/common/db/db.go @@ -0,0 +1,149 @@ +// Package db provides a transparent database wrapper that rewrites +// PostgreSQL-style SQL for SQLite when needed. +package db + +import ( + "context" + "database/sql" + + "github.com/jmoiron/sqlx" +) + +// DB wraps *sqlx.DB and transparently adapts PostgreSQL queries for SQLite. +// For PostgreSQL, all methods are simple pass-throughs with zero overhead. +type DB struct { + *sqlx.DB + sqlite bool +} + +// Wrap creates a DB wrapper around an existing *sqlx.DB connection. +// Returns nil if db is nil. +func Wrap(db *sqlx.DB) *DB { + if db == nil { + return nil + } + return &DB{ + DB: db, + sqlite: IsSQLite(db), + } +} + +func (d *DB) adapt(query string) string { + if !d.sqlite { + return query + } + return AdaptSQL(query) +} + +// Exec executes a query without returning any rows. +func (d *DB) Exec(query string, args ...interface{}) (sql.Result, error) { + return d.DB.Exec(d.adapt(query), args...) +} + +// ExecContext executes a query without returning any rows. +func (d *DB) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { + return d.DB.ExecContext(ctx, d.adapt(query), args...) +} + +// Query executes a query that returns rows. +func (d *DB) Query(query string, args ...interface{}) (*sql.Rows, error) { + return d.DB.Query(d.adapt(query), args...) +} + +// QueryContext executes a query that returns rows. +func (d *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { + return d.DB.QueryContext(ctx, d.adapt(query), args...) +} + +// QueryRow executes a query that is expected to return at most one row. +func (d *DB) QueryRow(query string, args ...interface{}) *sql.Row { + return d.DB.QueryRow(d.adapt(query), args...) +} + +// QueryRowContext executes a query that is expected to return at most one row. +func (d *DB) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row { + return d.DB.QueryRowContext(ctx, d.adapt(query), args...) +} + +// Get queries a single row and scans it into dest. +func (d *DB) Get(dest interface{}, query string, args ...interface{}) error { + return d.DB.Get(dest, d.adapt(query), args...) +} + +// Select queries multiple rows and scans them into dest. +func (d *DB) Select(dest interface{}, query string, args ...interface{}) error { + return d.DB.Select(dest, d.adapt(query), args...) +} + +// Queryx executes a query that returns sqlx.Rows. +func (d *DB) Queryx(query string, args ...interface{}) (*sqlx.Rows, error) { + return d.DB.Queryx(d.adapt(query), args...) +} + +// QueryRowx executes a query that returns a sqlx.Row. +func (d *DB) QueryRowx(query string, args ...interface{}) *sqlx.Row { + return d.DB.QueryRowx(d.adapt(query), args...) +} + +// QueryRowxContext executes a query that returns a sqlx.Row. +func (d *DB) QueryRowxContext(ctx context.Context, query string, args ...interface{}) *sqlx.Row { + return d.DB.QueryRowxContext(ctx, d.adapt(query), args...) +} + +// BeginTxx starts a new transaction with context and options. +// The returned Tx wrapper adapts queries the same way as DB. +func (d *DB) BeginTxx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) { + tx, err := d.DB.BeginTxx(ctx, opts) + if err != nil { + return nil, err + } + return &Tx{Tx: tx, sqlite: d.sqlite}, nil +} + +// Tx wraps *sqlx.Tx and transparently adapts PostgreSQL queries for SQLite. +type Tx struct { + *sqlx.Tx + sqlite bool +} + +func (t *Tx) adapt(query string) string { + if !t.sqlite { + return query + } + return AdaptSQL(query) +} + +// Exec executes a query without returning any rows. +func (t *Tx) Exec(query string, args ...interface{}) (sql.Result, error) { + return t.Tx.Exec(t.adapt(query), args...) +} + +// ExecContext executes a query without returning any rows. +func (t *Tx) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { + return t.Tx.ExecContext(ctx, t.adapt(query), args...) +} + +// Query executes a query that returns rows. +func (t *Tx) Query(query string, args ...interface{}) (*sql.Rows, error) { + return t.Tx.Query(t.adapt(query), args...) +} + +// QueryRow executes a query that is expected to return at most one row. +func (t *Tx) QueryRow(query string, args ...interface{}) *sql.Row { + return t.Tx.QueryRow(t.adapt(query), args...) +} + +// Queryx executes a query that returns sqlx.Rows. +func (t *Tx) Queryx(query string, args ...interface{}) (*sqlx.Rows, error) { + return t.Tx.Queryx(t.adapt(query), args...) +} + +// QueryRowx executes a query that returns a sqlx.Row. +func (t *Tx) QueryRowx(query string, args ...interface{}) *sqlx.Row { + return t.Tx.QueryRowx(t.adapt(query), args...) +} + +// Get queries a single row and scans it into dest. +func (t *Tx) Get(dest interface{}, query string, args ...interface{}) error { + return t.Tx.Get(dest, t.adapt(query), args...) +} diff --git a/config/config.go b/config/config.go index 8555fe701..1c558d06c 100644 --- a/config/config.go +++ b/config/config.go @@ -231,8 +231,12 @@ type Course struct { Enabled bool } -// Database holds the postgres database config. +// Database holds the database config. +// Driver can be "postgres" (default) or "sqlite". +// When Driver is "sqlite", only Database (file path) is used; +// Host, Port, User, and Password are ignored. type Database struct { + Driver string // "postgres" or "sqlite" Host string Port int User string @@ -454,7 +458,8 @@ func registerDefaults() { {Name: "EXRenewing", Enabled: true}, }) - // Database (Password deliberately has no default) + // Database (Password deliberately has no default for postgres) + viper.SetDefault("Database.Driver", "postgres") viper.SetDefault("Database.Host", "localhost") viper.SetDefault("Database.Port", 5432) viper.SetDefault("Database.User", "postgres") diff --git a/go.mod b/go.mod index b7b834a12..23fa722e1 100644 --- a/go.mod +++ b/go.mod @@ -16,13 +16,18 @@ require ( require ( github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.3.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -31,9 +36,13 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.41.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.46.1 // indirect ) diff --git a/go.sum b/go.sum index 576eb28df..c58be6bb9 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -109,6 +111,7 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -124,6 +127,8 @@ github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= @@ -159,10 +164,14 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -171,6 +180,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= @@ -237,6 +248,8 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -348,6 +361,7 @@ golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -521,6 +535,14 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/main.go b/main.go index edee6de24..8f764f6a3 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ import ( "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "go.uber.org/zap" + _ "modernc.org/sqlite" ) // Temporary DB auto clean on startup for quick development & testing. @@ -108,7 +109,7 @@ func main() { logger.Info(fmt.Sprintf("Starting Erupe (9.3b-%s)", Commit())) logger.Info(fmt.Sprintf("Client Mode: %s (%d)", config.ClientMode, config.RealClientMode)) - if config.Database.Password == "" { + if config.Database.Driver != "sqlite" && config.Database.Password == "" { preventClose(config, "Database password is blank") } @@ -136,19 +137,38 @@ func main() { logger.Info("Discord: Disabled") } - // Create the postgres DB pool. - connectString := fmt.Sprintf( - "host='%s' port='%d' user='%s' password='%s' dbname='%s' sslmode=disable", - config.Database.Host, - config.Database.Port, - config.Database.User, - config.Database.Password, - config.Database.Database, - ) - - db, err := sqlx.Open("postgres", connectString) - if err != nil { - preventClose(config, fmt.Sprintf("Database: Failed to open, %s", err.Error())) + // Create the DB pool. + var db *sqlx.DB + if config.Database.Driver == "sqlite" { + dbPath := config.Database.Database + if dbPath == "" || dbPath == "erupe" { + dbPath = "erupe.db" + } + db, err = sqlx.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)") + if err != nil { + preventClose(config, fmt.Sprintf("Database: Failed to open SQLite %s, %s", dbPath, err.Error())) + } + // SQLite only supports one writer at a time. + db.SetMaxOpenConns(1) + logger.Info(fmt.Sprintf("Database: SQLite opened (%s)", dbPath)) + } else { + connectString := fmt.Sprintf( + "host='%s' port='%d' user='%s' password='%s' dbname='%s' sslmode=disable", + config.Database.Host, + config.Database.Port, + config.Database.User, + config.Database.Password, + config.Database.Database, + ) + db, err = sqlx.Open("postgres", connectString) + if err != nil { + preventClose(config, fmt.Sprintf("Database: Failed to open, %s", err.Error())) + } + // Configure connection pool to avoid exhausting PostgreSQL under load. + db.SetMaxOpenConns(50) + db.SetMaxIdleConns(10) + db.SetConnMaxLifetime(5 * time.Minute) + db.SetConnMaxIdleTime(2 * time.Minute) } // Test the DB connection. @@ -157,12 +177,6 @@ func main() { preventClose(config, fmt.Sprintf("Database: Failed to ping, %s", err.Error())) } - // Configure connection pool to avoid exhausting PostgreSQL under load. - db.SetMaxOpenConns(50) - db.SetMaxIdleConns(10) - db.SetConnMaxLifetime(5 * time.Minute) - db.SetConnMaxIdleTime(2 * time.Minute) - logger.Info("Database: Started successfully") // Run database migrations diff --git a/server/api/api_server.go b/server/api/api_server.go index 6e106a088..13a3297cf 100644 --- a/server/api/api_server.go +++ b/server/api/api_server.go @@ -9,6 +9,8 @@ import ( "sync" "time" + dbutil "erupe-ce/common/db" + "github.com/gorilla/handlers" "github.com/gorilla/mux" "github.com/jmoiron/sqlx" @@ -33,6 +35,7 @@ type APIServer struct { sessionRepo APISessionRepo eventRepo APIEventRepo httpServer *http.Server + startTime time.Time isShuttingDown bool } @@ -45,19 +48,26 @@ func NewAPIServer(config *Config) *APIServer { httpServer: &http.Server{}, } if config.DB != nil { - s.userRepo = NewAPIUserRepository(config.DB) - s.charRepo = NewAPICharacterRepository(config.DB) - s.sessionRepo = NewAPISessionRepository(config.DB) - s.eventRepo = NewAPIEventRepository(config.DB) + wdb := dbutil.Wrap(config.DB) + s.userRepo = NewAPIUserRepository(wdb) + s.charRepo = NewAPICharacterRepository(wdb) + s.sessionRepo = NewAPISessionRepository(wdb) + s.eventRepo = NewAPIEventRepository(wdb) } return s } // Start starts the server in a new goroutine. func (s *APIServer) Start() error { + s.startTime = time.Now() + // Set up the routes responsible for serving the launcher HTML, serverlist, unique name check, and JP auth. r := mux.NewRouter() + // Dashboard routes (before catch-all) + r.HandleFunc("/dashboard", s.Dashboard) + r.HandleFunc("/api/dashboard/stats", s.DashboardStatsJSON).Methods("GET") + // Legacy routes (unchanged, no method enforcement) r.HandleFunc("/launcher", s.Launcher) r.HandleFunc("/login", s.Login) diff --git a/server/api/dashboard.go b/server/api/dashboard.go new file mode 100644 index 000000000..3b1b9ac66 --- /dev/null +++ b/server/api/dashboard.go @@ -0,0 +1,137 @@ +package api + +import ( + _ "embed" + "encoding/json" + "fmt" + "html/template" + "net/http" + "time" + + "go.uber.org/zap" +) + +//go:embed dashboard.html +var dashboardHTML string + +var dashboardTmpl = template.Must(template.New("dashboard").Parse(dashboardHTML)) + +// DashboardStats is the JSON payload returned by GET /api/dashboard/stats. +type DashboardStats struct { + Uptime string `json:"uptime"` + ServerVersion string `json:"serverVersion"` + ClientMode string `json:"clientMode"` + OnlinePlayers int `json:"onlinePlayers"` + TotalAccounts int `json:"totalAccounts"` + TotalCharacters int `json:"totalCharacters"` + Channels []ChannelInfo `json:"channels"` + DatabaseOK bool `json:"databaseOK"` +} + +// ChannelInfo describes a single channel server entry from the servers table. +type ChannelInfo struct { + Name string `json:"name"` + Port int `json:"port"` + Players int `json:"players"` +} + +// Dashboard serves the embedded HTML dashboard page at /dashboard. +func (s *APIServer) Dashboard(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := dashboardTmpl.Execute(w, nil); err != nil { + s.logger.Error("Failed to render dashboard", zap.Error(err)) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +// DashboardStatsJSON serves GET /api/dashboard/stats with live server statistics. +func (s *APIServer) DashboardStatsJSON(w http.ResponseWriter, r *http.Request) { + stats := DashboardStats{ + ServerVersion: "Erupe-CE", + ClientMode: s.erupeConfig.ClientMode, + } + + // Compute uptime. + if !s.startTime.IsZero() { + stats.Uptime = formatDuration(time.Since(s.startTime)) + } else { + stats.Uptime = "unknown" + } + + // Check database connectivity. + if s.db != nil { + if err := s.db.Ping(); err != nil { + s.logger.Warn("Dashboard: database ping failed", zap.Error(err)) + stats.DatabaseOK = false + } else { + stats.DatabaseOK = true + } + } + + // Query total accounts. + if s.db != nil { + if err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&stats.TotalAccounts); err != nil { + s.logger.Warn("Dashboard: failed to count users", zap.Error(err)) + } + } + + // Query total characters. + if s.db != nil { + if err := s.db.QueryRow("SELECT COUNT(*) FROM characters").Scan(&stats.TotalCharacters); err != nil { + s.logger.Warn("Dashboard: failed to count characters", zap.Error(err)) + } + } + + // Query channel info from servers table. + if s.db != nil { + rows, err := s.db.Query("SELECT server_id, current_players, world_name, land FROM servers ORDER BY server_id") + if err != nil { + s.logger.Warn("Dashboard: failed to query servers", zap.Error(err)) + } else { + defer func() { _ = rows.Close() }() + for rows.Next() { + var serverID, players, land int + var worldName *string + if err := rows.Scan(&serverID, &players, &worldName, &land); err != nil { + s.logger.Warn("Dashboard: failed to scan server row", zap.Error(err)) + continue + } + name := "Channel" + if worldName != nil { + name = *worldName + } + ch := ChannelInfo{ + Name: name, + Port: 54000 + serverID, + Players: players, + } + stats.Channels = append(stats.Channels, ch) + stats.OnlinePlayers += players + } + } + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(stats); err != nil { + s.logger.Error("Dashboard: failed to encode stats", zap.Error(err)) + } +} + +// formatDuration produces a human-readable duration string like "2d 5h 32m 10s". +func formatDuration(d time.Duration) string { + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + seconds := int(d.Seconds()) % 60 + + if days > 0 { + return fmt.Sprintf("%dd %dh %dm %ds", days, hours, minutes, seconds) + } + if hours > 0 { + return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds) + } + if minutes > 0 { + return fmt.Sprintf("%dm %ds", minutes, seconds) + } + return fmt.Sprintf("%ds", seconds) +} diff --git a/server/api/dashboard.html b/server/api/dashboard.html new file mode 100644 index 000000000..f5553fe83 --- /dev/null +++ b/server/api/dashboard.html @@ -0,0 +1,145 @@ + + + + + +Erupe Dashboard + + + +
+

Erupe Dashboard

+
Loading...
+
+ +
Failed to fetch server stats
+ +
+
+
Uptime
+
--
+
+
+
Online Players
+
--
+
+
+
Total Accounts
+
--
+
+
+
Total Characters
+
--
+
+
+
Database
+
--
+
+
+ +
+

Channels

+
+
Loading...
+
+
+ + + + + + diff --git a/server/api/repo_character.go b/server/api/repo_character.go index 6bddc5815..7d3ffa3f6 100644 --- a/server/api/repo_character.go +++ b/server/api/repo_character.go @@ -3,16 +3,16 @@ package api import ( "context" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // APICharacterRepository implements APICharacterRepo with PostgreSQL. type APICharacterRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewAPICharacterRepository creates a new APICharacterRepository. -func NewAPICharacterRepository(db *sqlx.DB) *APICharacterRepository { +func NewAPICharacterRepository(db *dbutil.DB) *APICharacterRepository { return &APICharacterRepository{db: db} } diff --git a/server/api/repo_event.go b/server/api/repo_event.go index c6a2cef2c..40694fb66 100644 --- a/server/api/repo_event.go +++ b/server/api/repo_event.go @@ -6,15 +6,15 @@ import ( "errors" "time" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) type apiEventRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewAPIEventRepository creates an APIEventRepo backed by PostgreSQL. -func NewAPIEventRepository(db *sqlx.DB) APIEventRepo { +func NewAPIEventRepository(db *dbutil.DB) APIEventRepo { return &apiEventRepository{db: db} } diff --git a/server/api/repo_session.go b/server/api/repo_session.go index 80a842d00..dffc3fa25 100644 --- a/server/api/repo_session.go +++ b/server/api/repo_session.go @@ -3,16 +3,16 @@ package api import ( "context" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // APISessionRepository implements APISessionRepo with PostgreSQL. type APISessionRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewAPISessionRepository creates a new APISessionRepository. -func NewAPISessionRepository(db *sqlx.DB) *APISessionRepository { +func NewAPISessionRepository(db *dbutil.DB) *APISessionRepository { return &APISessionRepository{db: db} } diff --git a/server/api/repo_user.go b/server/api/repo_user.go index dfb25664f..37af109b1 100644 --- a/server/api/repo_user.go +++ b/server/api/repo_user.go @@ -4,16 +4,16 @@ import ( "context" "time" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // APIUserRepository implements APIUserRepo with PostgreSQL. type APIUserRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewAPIUserRepository creates a new APIUserRepository. -func NewAPIUserRepository(db *sqlx.DB) *APIUserRepository { +func NewAPIUserRepository(db *dbutil.DB) *APIUserRepository { return &APIUserRepository{db: db} } diff --git a/server/channelserver/handlers_cafe.go b/server/channelserver/handlers_cafe.go index 2dfe0d6e3..2f2ff4fb8 100644 --- a/server/channelserver/handlers_cafe.go +++ b/server/channelserver/handlers_cafe.go @@ -170,8 +170,8 @@ func handleMsgMhfPostCafeDurationBonusReceived(s *Session, p mhfpacket.MHFPacket if err == nil { if itemType == 17 { if err := addPointNetcafe(s, int(quantity)); err != nil { - s.logger.Error("Failed to add cafe bonus netcafe points", zap.Error(err)) - } + s.logger.Error("Failed to add cafe bonus netcafe points", zap.Error(err)) + } } } if err := s.server.cafeRepo.AcceptBonus(cbID, s.charID); err != nil { diff --git a/server/channelserver/handlers_cafe_test.go b/server/channelserver/handlers_cafe_test.go index 0e99cb765..f46dcc769 100644 --- a/server/channelserver/handlers_cafe_test.go +++ b/server/channelserver/handlers_cafe_test.go @@ -1,10 +1,10 @@ package channelserver import ( - cfg "erupe-ce/config" - "erupe-ce/common/mhfcourse" - "erupe-ce/network/mhfpacket" "errors" + "erupe-ce/common/mhfcourse" + cfg "erupe-ce/config" + "erupe-ce/network/mhfpacket" "testing" "time" ) diff --git a/server/channelserver/handlers_distitem.go b/server/channelserver/handlers_distitem.go index 1ee5eb7b8..7f4d53260 100644 --- a/server/channelserver/handlers_distitem.go +++ b/server/channelserver/handlers_distitem.go @@ -140,8 +140,8 @@ func handleMsgMhfAcquireDistItem(s *Session, p mhfpacket.MHFPacket) { switch item.ItemType { case 17: if err := addPointNetcafe(s, int(item.Quantity)); err != nil { - s.logger.Error("Failed to add dist item netcafe points", zap.Error(err)) - } + s.logger.Error("Failed to add dist item netcafe points", zap.Error(err)) + } case 19: if err := s.server.userRepo.AddPremiumCoins(s.userID, item.Quantity); err != nil { s.logger.Error("Failed to update gacha premium", zap.Error(err)) diff --git a/server/channelserver/handlers_diva_test.go b/server/channelserver/handlers_diva_test.go index 9d53d44da..64f6501f5 100644 --- a/server/channelserver/handlers_diva_test.go +++ b/server/channelserver/handlers_diva_test.go @@ -3,9 +3,9 @@ package channelserver import ( "testing" + cfg "erupe-ce/config" "erupe-ce/network/mhfpacket" "time" - cfg "erupe-ce/config" ) func TestHandleMsgMhfGetUdInfo(t *testing.T) { diff --git a/server/channelserver/handlers_guild_ops_test.go b/server/channelserver/handlers_guild_ops_test.go index d61284082..3769a0733 100644 --- a/server/channelserver/handlers_guild_ops_test.go +++ b/server/channelserver/handlers_guild_ops_test.go @@ -4,8 +4,8 @@ import ( "testing" "erupe-ce/common/byteframe" - "erupe-ce/network/mhfpacket" "erupe-ce/common/stringsupport" + "erupe-ce/network/mhfpacket" ) // --- handleMsgMhfOperateGuild tests --- diff --git a/server/channelserver/handlers_guild_scout_test.go b/server/channelserver/handlers_guild_scout_test.go index 82e981619..f41973a05 100644 --- a/server/channelserver/handlers_guild_scout_test.go +++ b/server/channelserver/handlers_guild_scout_test.go @@ -297,7 +297,7 @@ func TestPostGuildScout_Success(t *testing.T) { func TestPostGuildScout_AlreadyInvited(t *testing.T) { server := createMockServer() guildMock := &mockGuildRepo{ - membership: &GuildMember{GuildID: 10, Recruiter: true}, + membership: &GuildMember{GuildID: 10, Recruiter: true}, createAppErr: ErrAlreadyInvited, } guildMock.guild = &Guild{ID: 10, Name: "TestGuild"} diff --git a/server/channelserver/handlers_mercenary_test.go b/server/channelserver/handlers_mercenary_test.go index 74d963379..f0e3a3a38 100644 --- a/server/channelserver/handlers_mercenary_test.go +++ b/server/channelserver/handlers_mercenary_test.go @@ -6,8 +6,8 @@ import ( "testing" "erupe-ce/common/byteframe" - "erupe-ce/network/mhfpacket" cfg "erupe-ce/config" + "erupe-ce/network/mhfpacket" ) func TestHandleMsgMhfLoadLegendDispatch(t *testing.T) { diff --git a/server/channelserver/handlers_shop.go b/server/channelserver/handlers_shop.go index 081528256..a1747de5e 100644 --- a/server/channelserver/handlers_shop.go +++ b/server/channelserver/handlers_shop.go @@ -156,13 +156,13 @@ func handleMsgMhfEnumerateShop(s *Session, p mhfpacket.MHFPacket) { // |5|1|1|7|9|3|0|0|0|0|0|null| // |8|1|100|7|1|4|1000|0|0|0|0|null| // |9|1|100|7|2|5|9000|0|0|0|0|null| - bf.WriteUint8(ge.EntryType) - bf.WriteUint32(ge.ID) - bf.WriteUint8(ge.ItemType) - bf.WriteUint32(ge.ItemNumber) - bf.WriteUint16(ge.ItemQuantity) + bf.WriteUint8(ge.EntryType) + bf.WriteUint32(ge.ID) + bf.WriteUint8(ge.ItemType) + bf.WriteUint32(ge.ItemNumber) + bf.WriteUint16(ge.ItemQuantity) var weightPr uint16 - if gachaType >= 4 { // If box + if gachaType >= 4 { // If box weightPr = 1 } else { weightPr = uint16(ge.Weight / divisor) diff --git a/server/channelserver/repo_achievement.go b/server/channelserver/repo_achievement.go index 26e12dd79..578fdbcee 100644 --- a/server/channelserver/repo_achievement.go +++ b/server/channelserver/repo_achievement.go @@ -3,16 +3,16 @@ package channelserver import ( "fmt" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // AchievementRepository centralizes all database access for the achievements table. type AchievementRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewAchievementRepository creates a new AchievementRepository. -func NewAchievementRepository(db *sqlx.DB) *AchievementRepository { +func NewAchievementRepository(db *dbutil.DB) *AchievementRepository { return &AchievementRepository{db: db} } diff --git a/server/channelserver/repo_achievement_test.go b/server/channelserver/repo_achievement_test.go index ae9a08cc8..371ba3d94 100644 --- a/server/channelserver/repo_achievement_test.go +++ b/server/channelserver/repo_achievement_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -11,7 +12,7 @@ func setupAchievementRepo(t *testing.T) (*AchievementRepository, *sqlx.DB, uint3 db := SetupTestDB(t) userID := CreateTestUser(t, db, "ach_test_user") charID := CreateTestCharacter(t, db, userID, "AchChar") - repo := NewAchievementRepository(db) + repo := NewAchievementRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, charID } diff --git a/server/channelserver/repo_cafe.go b/server/channelserver/repo_cafe.go index 064ef4ffe..7781f7192 100644 --- a/server/channelserver/repo_cafe.go +++ b/server/channelserver/repo_cafe.go @@ -1,16 +1,16 @@ package channelserver import ( - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // CafeRepository centralizes all database access for cafe-related tables. type CafeRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewCafeRepository creates a new CafeRepository. -func NewCafeRepository(db *sqlx.DB) *CafeRepository { +func NewCafeRepository(db *dbutil.DB) *CafeRepository { return &CafeRepository{db: db} } diff --git a/server/channelserver/repo_cafe_test.go b/server/channelserver/repo_cafe_test.go index e1c43e98a..5601ece73 100644 --- a/server/channelserver/repo_cafe_test.go +++ b/server/channelserver/repo_cafe_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -11,7 +12,7 @@ func setupCafeRepo(t *testing.T) (*CafeRepository, *sqlx.DB, uint32) { db := SetupTestDB(t) userID := CreateTestUser(t, db, "cafe_test_user") charID := CreateTestCharacter(t, db, userID, "CafeChar") - repo := NewCafeRepository(db) + repo := NewCafeRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, charID } diff --git a/server/channelserver/repo_character.go b/server/channelserver/repo_character.go index 89028c6c8..6a72baf9a 100644 --- a/server/channelserver/repo_character.go +++ b/server/channelserver/repo_character.go @@ -4,16 +4,16 @@ import ( "database/sql" "time" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // CharacterRepository centralizes all database access for the characters table. type CharacterRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewCharacterRepository creates a new CharacterRepository. -func NewCharacterRepository(db *sqlx.DB) *CharacterRepository { +func NewCharacterRepository(db *dbutil.DB) *CharacterRepository { return &CharacterRepository{db: db} } diff --git a/server/channelserver/repo_character_test.go b/server/channelserver/repo_character_test.go index 52ebdd780..945bb4967 100644 --- a/server/channelserver/repo_character_test.go +++ b/server/channelserver/repo_character_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "time" @@ -12,7 +13,7 @@ func setupCharRepo(t *testing.T) (*CharacterRepository, *sqlx.DB, uint32) { db := SetupTestDB(t) userID := CreateTestUser(t, db, "repo_test_user") charID := CreateTestCharacter(t, db, userID, "RepoChar") - repo := NewCharacterRepository(db) + repo := NewCharacterRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, charID } diff --git a/server/channelserver/repo_distribution.go b/server/channelserver/repo_distribution.go index 7ef8a42f6..d4193956d 100644 --- a/server/channelserver/repo_distribution.go +++ b/server/channelserver/repo_distribution.go @@ -1,17 +1,17 @@ package channelserver import ( - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // DistributionRepository centralizes all database access for the distribution, // distribution_items, and distributions_accepted tables. type DistributionRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewDistributionRepository creates a new DistributionRepository. -func NewDistributionRepository(db *sqlx.DB) *DistributionRepository { +func NewDistributionRepository(db *dbutil.DB) *DistributionRepository { return &DistributionRepository{db: db} } diff --git a/server/channelserver/repo_distribution_test.go b/server/channelserver/repo_distribution_test.go index fc2286142..fae6f556b 100644 --- a/server/channelserver/repo_distribution_test.go +++ b/server/channelserver/repo_distribution_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -11,7 +12,7 @@ func setupDistributionRepo(t *testing.T) (*DistributionRepository, *sqlx.DB, uin db := SetupTestDB(t) userID := CreateTestUser(t, db, "dist_test_user") charID := CreateTestCharacter(t, db, userID, "DistChar") - repo := NewDistributionRepository(db) + repo := NewDistributionRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, charID } diff --git a/server/channelserver/repo_diva.go b/server/channelserver/repo_diva.go index 90b53e201..4cd16ee49 100644 --- a/server/channelserver/repo_diva.go +++ b/server/channelserver/repo_diva.go @@ -1,16 +1,16 @@ package channelserver import ( - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // DivaRepository centralizes all database access for diva defense events. type DivaRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewDivaRepository creates a new DivaRepository. -func NewDivaRepository(db *sqlx.DB) *DivaRepository { +func NewDivaRepository(db *dbutil.DB) *DivaRepository { return &DivaRepository{db: db} } diff --git a/server/channelserver/repo_diva_test.go b/server/channelserver/repo_diva_test.go index bd6ab0d60..b7b623657 100644 --- a/server/channelserver/repo_diva_test.go +++ b/server/channelserver/repo_diva_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -9,7 +10,7 @@ import ( func setupDivaRepo(t *testing.T) (*DivaRepository, *sqlx.DB) { t.Helper() db := SetupTestDB(t) - repo := NewDivaRepository(db) + repo := NewDivaRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db } diff --git a/server/channelserver/repo_event.go b/server/channelserver/repo_event.go index eaae596e7..649a4ea5e 100644 --- a/server/channelserver/repo_event.go +++ b/server/channelserver/repo_event.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // EventQuest represents a row from the event_quests table. @@ -22,11 +22,11 @@ type EventQuest struct { // EventRepository centralizes all database access for event-related tables. type EventRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewEventRepository creates a new EventRepository. -func NewEventRepository(db *sqlx.DB) *EventRepository { +func NewEventRepository(db *dbutil.DB) *EventRepository { return &EventRepository{db: db} } diff --git a/server/channelserver/repo_event_test.go b/server/channelserver/repo_event_test.go index 36ad33b56..5ad3d6f06 100644 --- a/server/channelserver/repo_event_test.go +++ b/server/channelserver/repo_event_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "time" @@ -10,7 +11,7 @@ import ( func setupEventRepo(t *testing.T) (*EventRepository, *sqlx.DB) { t.Helper() db := SetupTestDB(t) - repo := NewEventRepository(db) + repo := NewEventRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db } diff --git a/server/channelserver/repo_festa.go b/server/channelserver/repo_festa.go index dbac352dd..bc694658d 100644 --- a/server/channelserver/repo_festa.go +++ b/server/channelserver/repo_festa.go @@ -4,17 +4,17 @@ import ( "context" "database/sql" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // FestaRepository centralizes all database access for festa-related tables // (events, festa_registrations, festa_submissions, festa_prizes, festa_prizes_accepted, festa_trials, guild_characters). type FestaRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewFestaRepository creates a new FestaRepository. -func NewFestaRepository(db *sqlx.DB) *FestaRepository { +func NewFestaRepository(db *dbutil.DB) *FestaRepository { return &FestaRepository{db: db} } diff --git a/server/channelserver/repo_festa_test.go b/server/channelserver/repo_festa_test.go index 0ef98bb77..f94f137f2 100644 --- a/server/channelserver/repo_festa_test.go +++ b/server/channelserver/repo_festa_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "time" @@ -13,7 +14,7 @@ func setupFestaRepo(t *testing.T) (*FestaRepository, *sqlx.DB, uint32, uint32) { userID := CreateTestUser(t, db, "festa_test_user") charID := CreateTestCharacter(t, db, userID, "FestaChar") guildID := CreateTestGuild(t, db, charID, "FestaGuild") - repo := NewFestaRepository(db) + repo := NewFestaRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, charID, guildID } diff --git a/server/channelserver/repo_gacha.go b/server/channelserver/repo_gacha.go index b2efcf303..8a6011660 100644 --- a/server/channelserver/repo_gacha.go +++ b/server/channelserver/repo_gacha.go @@ -5,17 +5,17 @@ import ( "errors" "time" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // GachaRepository centralizes all database access for gacha-related tables // (gacha_shop, gacha_entries, gacha_items, gacha_stepup, gacha_box). type GachaRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewGachaRepository creates a new GachaRepository. -func NewGachaRepository(db *sqlx.DB) *GachaRepository { +func NewGachaRepository(db *dbutil.DB) *GachaRepository { return &GachaRepository{db: db} } diff --git a/server/channelserver/repo_gacha_test.go b/server/channelserver/repo_gacha_test.go index 64f8c4a71..6b4d63683 100644 --- a/server/channelserver/repo_gacha_test.go +++ b/server/channelserver/repo_gacha_test.go @@ -3,6 +3,7 @@ package channelserver import ( "database/sql" "errors" + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -13,7 +14,7 @@ func setupGachaRepo(t *testing.T) (*GachaRepository, *sqlx.DB, uint32) { db := SetupTestDB(t) userID := CreateTestUser(t, db, "gacha_test_user") charID := CreateTestCharacter(t, db, userID, "GachaChar") - repo := NewGachaRepository(db) + repo := NewGachaRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, charID } diff --git a/server/channelserver/repo_goocoo.go b/server/channelserver/repo_goocoo.go index dc9c072da..f84608677 100644 --- a/server/channelserver/repo_goocoo.go +++ b/server/channelserver/repo_goocoo.go @@ -3,16 +3,16 @@ package channelserver import ( "fmt" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // GoocooRepository centralizes all database access for the goocoo table. type GoocooRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewGoocooRepository creates a new GoocooRepository. -func NewGoocooRepository(db *sqlx.DB) *GoocooRepository { +func NewGoocooRepository(db *dbutil.DB) *GoocooRepository { return &GoocooRepository{db: db} } diff --git a/server/channelserver/repo_goocoo_test.go b/server/channelserver/repo_goocoo_test.go index 0b390402d..a1d8981c7 100644 --- a/server/channelserver/repo_goocoo_test.go +++ b/server/channelserver/repo_goocoo_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -11,7 +12,7 @@ func setupGoocooRepo(t *testing.T) (*GoocooRepository, *sqlx.DB, uint32) { db := SetupTestDB(t) userID := CreateTestUser(t, db, "goocoo_test_user") charID := CreateTestCharacter(t, db, userID, "GoocooChar") - repo := NewGoocooRepository(db) + repo := NewGoocooRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, charID } diff --git a/server/channelserver/repo_guild.go b/server/channelserver/repo_guild.go index f9eca11e5..a005437d0 100644 --- a/server/channelserver/repo_guild.go +++ b/server/channelserver/repo_guild.go @@ -6,17 +6,19 @@ import ( "errors" "fmt" + dbutil "erupe-ce/common/db" + "github.com/jmoiron/sqlx" ) // GuildRepository centralizes all database access for guild-related tables // (guilds, guild_characters, guild_applications). type GuildRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewGuildRepository creates a new GuildRepository. -func NewGuildRepository(db *sqlx.DB) *GuildRepository { +func NewGuildRepository(db *dbutil.DB) *GuildRepository { return &GuildRepository{db: db} } diff --git a/server/channelserver/repo_guild_test.go b/server/channelserver/repo_guild_test.go index fc2ff6c3c..06aae1354 100644 --- a/server/channelserver/repo_guild_test.go +++ b/server/channelserver/repo_guild_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "fmt" "testing" "time" @@ -13,7 +14,7 @@ func setupGuildRepo(t *testing.T) (*GuildRepository, *sqlx.DB, uint32, uint32) { db := SetupTestDB(t) userID := CreateTestUser(t, db, "guild_test_user") charID := CreateTestCharacter(t, db, userID, "GuildLeader") - repo := NewGuildRepository(db) + repo := NewGuildRepository(dbutil.Wrap(db)) guildID := CreateTestGuild(t, db, charID, "TestGuild") t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, guildID, charID @@ -82,7 +83,7 @@ func TestGetByCharIDNotFound(t *testing.T) { func TestCreate(t *testing.T) { db := SetupTestDB(t) defer TeardownTestDB(t, db) - repo := NewGuildRepository(db) + repo := NewGuildRepository(dbutil.Wrap(db)) userID := CreateTestUser(t, db, "create_guild_user") charID := CreateTestCharacter(t, db, userID, "CreateLeader") diff --git a/server/channelserver/repo_house.go b/server/channelserver/repo_house.go index f976a71f5..61185d3f7 100644 --- a/server/channelserver/repo_house.go +++ b/server/channelserver/repo_house.go @@ -3,17 +3,17 @@ package channelserver import ( "fmt" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // HouseRepository centralizes all database access for house-related tables // (user_binary house columns, warehouse, titles). type HouseRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewHouseRepository creates a new HouseRepository. -func NewHouseRepository(db *sqlx.DB) *HouseRepository { +func NewHouseRepository(db *dbutil.DB) *HouseRepository { return &HouseRepository{db: db} } diff --git a/server/channelserver/repo_house_test.go b/server/channelserver/repo_house_test.go index 2bd841bd7..21ec0a670 100644 --- a/server/channelserver/repo_house_test.go +++ b/server/channelserver/repo_house_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -12,7 +13,7 @@ func setupHouseRepo(t *testing.T) (*HouseRepository, *sqlx.DB, uint32) { userID := CreateTestUser(t, db, "house_test_user") charID := CreateTestCharacter(t, db, userID, "HouseChar") CreateTestUserBinary(t, db, charID) - repo := NewHouseRepository(db) + repo := NewHouseRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, charID } diff --git a/server/channelserver/repo_mail.go b/server/channelserver/repo_mail.go index b9023f6da..dbd3a06ae 100644 --- a/server/channelserver/repo_mail.go +++ b/server/channelserver/repo_mail.go @@ -1,16 +1,16 @@ package channelserver import ( - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // MailRepository centralizes all database access for the mail table. type MailRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewMailRepository creates a new MailRepository. -func NewMailRepository(db *sqlx.DB) *MailRepository { +func NewMailRepository(db *dbutil.DB) *MailRepository { return &MailRepository{db: db} } diff --git a/server/channelserver/repo_mail_test.go b/server/channelserver/repo_mail_test.go index 101b93ef7..125f86cfe 100644 --- a/server/channelserver/repo_mail_test.go +++ b/server/channelserver/repo_mail_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -13,7 +14,7 @@ func setupMailRepo(t *testing.T) (*MailRepository, *sqlx.DB, uint32, uint32) { senderID := CreateTestCharacter(t, db, userID, "Sender") userID2 := CreateTestUser(t, db, "mail_recipient") recipientID := CreateTestCharacter(t, db, userID2, "Recipient") - repo := NewMailRepository(db) + repo := NewMailRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, senderID, recipientID } diff --git a/server/channelserver/repo_mercenary.go b/server/channelserver/repo_mercenary.go index e844ae24a..da600a34a 100644 --- a/server/channelserver/repo_mercenary.go +++ b/server/channelserver/repo_mercenary.go @@ -4,16 +4,16 @@ import ( "fmt" "time" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // MercenaryRepository centralizes database access for mercenary/rasta/airou sequences and queries. type MercenaryRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewMercenaryRepository creates a new MercenaryRepository. -func NewMercenaryRepository(db *sqlx.DB) *MercenaryRepository { +func NewMercenaryRepository(db *dbutil.DB) *MercenaryRepository { return &MercenaryRepository{db: db} } diff --git a/server/channelserver/repo_mercenary_test.go b/server/channelserver/repo_mercenary_test.go index 660b5995b..80a5263c7 100644 --- a/server/channelserver/repo_mercenary_test.go +++ b/server/channelserver/repo_mercenary_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -12,7 +13,7 @@ func setupMercenaryRepo(t *testing.T) (*MercenaryRepository, *sqlx.DB, uint32, u userID := CreateTestUser(t, db, "merc_test_user") charID := CreateTestCharacter(t, db, userID, "MercChar") guildID := CreateTestGuild(t, db, charID, "MercGuild") - repo := NewMercenaryRepository(db) + repo := NewMercenaryRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, charID, guildID } diff --git a/server/channelserver/repo_misc.go b/server/channelserver/repo_misc.go index f99eaa536..f650fd4bf 100644 --- a/server/channelserver/repo_misc.go +++ b/server/channelserver/repo_misc.go @@ -3,16 +3,16 @@ package channelserver import ( "fmt" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // MiscRepository centralizes database access for miscellaneous game tables. type MiscRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewMiscRepository creates a new MiscRepository. -func NewMiscRepository(db *sqlx.DB) *MiscRepository { +func NewMiscRepository(db *dbutil.DB) *MiscRepository { return &MiscRepository{db: db} } diff --git a/server/channelserver/repo_misc_test.go b/server/channelserver/repo_misc_test.go index 7a16def39..ef3b0fbaa 100644 --- a/server/channelserver/repo_misc_test.go +++ b/server/channelserver/repo_misc_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -9,7 +10,7 @@ import ( func setupMiscRepo(t *testing.T) (*MiscRepository, *sqlx.DB) { t.Helper() db := SetupTestDB(t) - repo := NewMiscRepository(db) + repo := NewMiscRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db } diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index fdd262e4a..b30707cd2 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -304,15 +304,15 @@ type mockGuildRepo struct { deletedPostID uint32 // Alliance - alliance *GuildAlliance - getAllianceErr error - createAllianceErr error - deleteAllianceErr error - removeAllyErr error - setAllianceRecruitErr error - deletedAllianceID uint32 - removedAllyArgs []uint32 - allianceRecruitingSet *bool + alliance *GuildAlliance + getAllianceErr error + createAllianceErr error + deleteAllianceErr error + removeAllyErr error + setAllianceRecruitErr error + deletedAllianceID uint32 + removedAllyArgs []uint32 + allianceRecruitingSet *bool // Cooking meals []*GuildMeal @@ -552,28 +552,28 @@ func (m *mockGuildRepo) Create(_ uint32, _ string) (int32, error) { return 0, ni func (m *mockGuildRepo) CreateApplicationWithMail(_, _, _ uint32, _ GuildApplicationType, _, _ uint32, _, _ string) error { return nil } -func (m *mockGuildRepo) CancelInvitation(_, _ uint32) error { return nil } -func (m *mockGuildRepo) ArrangeCharacters(_ []uint32) error { return nil } -func (m *mockGuildRepo) GetItemBox(_ uint32) ([]byte, error) { return nil, nil } -func (m *mockGuildRepo) SaveItemBox(_ uint32, _ []byte) error { return nil } -func (m *mockGuildRepo) SetRecruiting(_ uint32, _ bool) error { return nil } -func (m *mockGuildRepo) SetPugiOutfits(_ uint32, _ uint32) error { return nil } -func (m *mockGuildRepo) SetRecruiter(_ uint32, _ bool) error { return nil } -func (m *mockGuildRepo) AddMemberDailyRP(_ uint32, _ uint16) error { return nil } -func (m *mockGuildRepo) ExchangeEventRP(_ uint32, _ uint16) (uint32, error) { return 0, nil } -func (m *mockGuildRepo) AddRankRP(_ uint32, _ uint16) error { return nil } -func (m *mockGuildRepo) AddEventRP(_ uint32, _ uint16) error { return nil } -func (m *mockGuildRepo) GetRoomRP(_ uint32) (uint16, error) { return 0, nil } -func (m *mockGuildRepo) SetRoomRP(_ uint32, _ uint16) error { return nil } -func (m *mockGuildRepo) AddRoomRP(_ uint32, _ uint16) error { return nil } -func (m *mockGuildRepo) SetRoomExpiry(_ uint32, _ time.Time) error { return nil } -func (m *mockGuildRepo) UpdatePost(_ uint32, _, _ string) error { return nil } -func (m *mockGuildRepo) UpdatePostStamp(_, _ uint32) error { return nil } -func (m *mockGuildRepo) GetPostLikedBy(_ uint32) (string, error) { return "", nil } -func (m *mockGuildRepo) SetPostLikedBy(_ uint32, _ string) error { return nil } -func (m *mockGuildRepo) CountNewPosts(_ uint32, _ time.Time) (int, error) { return 0, nil } -func (m *mockGuildRepo) ListAlliances() ([]*GuildAlliance, error) { return nil, nil } -func (m *mockGuildRepo) ClearTreasureHunt(_ uint32) error { return nil } +func (m *mockGuildRepo) CancelInvitation(_, _ uint32) error { return nil } +func (m *mockGuildRepo) ArrangeCharacters(_ []uint32) error { return nil } +func (m *mockGuildRepo) GetItemBox(_ uint32) ([]byte, error) { return nil, nil } +func (m *mockGuildRepo) SaveItemBox(_ uint32, _ []byte) error { return nil } +func (m *mockGuildRepo) SetRecruiting(_ uint32, _ bool) error { return nil } +func (m *mockGuildRepo) SetPugiOutfits(_ uint32, _ uint32) error { return nil } +func (m *mockGuildRepo) SetRecruiter(_ uint32, _ bool) error { return nil } +func (m *mockGuildRepo) AddMemberDailyRP(_ uint32, _ uint16) error { return nil } +func (m *mockGuildRepo) ExchangeEventRP(_ uint32, _ uint16) (uint32, error) { return 0, nil } +func (m *mockGuildRepo) AddRankRP(_ uint32, _ uint16) error { return nil } +func (m *mockGuildRepo) AddEventRP(_ uint32, _ uint16) error { return nil } +func (m *mockGuildRepo) GetRoomRP(_ uint32) (uint16, error) { return 0, nil } +func (m *mockGuildRepo) SetRoomRP(_ uint32, _ uint16) error { return nil } +func (m *mockGuildRepo) AddRoomRP(_ uint32, _ uint16) error { return nil } +func (m *mockGuildRepo) SetRoomExpiry(_ uint32, _ time.Time) error { return nil } +func (m *mockGuildRepo) UpdatePost(_ uint32, _, _ string) error { return nil } +func (m *mockGuildRepo) UpdatePostStamp(_, _ uint32) error { return nil } +func (m *mockGuildRepo) GetPostLikedBy(_ uint32) (string, error) { return "", nil } +func (m *mockGuildRepo) SetPostLikedBy(_ uint32, _ string) error { return nil } +func (m *mockGuildRepo) CountNewPosts(_ uint32, _ time.Time) (int, error) { return 0, nil } +func (m *mockGuildRepo) ListAlliances() ([]*GuildAlliance, error) { return nil, nil } +func (m *mockGuildRepo) ClearTreasureHunt(_ uint32) error { return nil } func (m *mockGuildRepo) InsertKillLog(_ uint32, _ int, _ uint8, _ time.Time) error { return nil } func (m *mockGuildRepo) ListInvitedCharacters(_ uint32) ([]*ScoutedCharacter, error) { return nil, nil @@ -816,7 +816,6 @@ type mockGachaRepo struct { allEntries []GachaEntry allEntriesErr error weightDivisor float64 - } func (m *mockGachaRepo) GetEntryForTransaction(_ uint32, _ uint8) (uint8, uint16, int, error) { @@ -957,26 +956,26 @@ type mockTowerRepo struct { gemsErr error updatedGems string - progress TenrouiraiProgressData - progressErr error - scores []TenrouiraiCharScore - scoresErr error - guildRP uint32 - guildRPErr error - page int - donated int - pageRPErr error - advanceErr error - advanceCalled bool - donateErr error - donatedRP uint16 + progress TenrouiraiProgressData + progressErr error + scores []TenrouiraiCharScore + scoresErr error + guildRP uint32 + guildRPErr error + page int + donated int + pageRPErr error + advanceErr error + advanceCalled bool + donateErr error + donatedRP uint16 } -func (m *mockTowerRepo) GetTowerData(_ uint32) (TowerData, error) { return m.towerData, m.towerDataErr } -func (m *mockTowerRepo) GetSkills(_ uint32) (string, error) { return m.skills, m.skillsErr } -func (m *mockTowerRepo) UpdateSkills(_ uint32, _ string, _ int32) error { return nil } +func (m *mockTowerRepo) GetTowerData(_ uint32) (TowerData, error) { return m.towerData, m.towerDataErr } +func (m *mockTowerRepo) GetSkills(_ uint32) (string, error) { return m.skills, m.skillsErr } +func (m *mockTowerRepo) UpdateSkills(_ uint32, _ string, _ int32) error { return nil } func (m *mockTowerRepo) UpdateProgress(_ uint32, _, _, _, _ int32) error { return nil } -func (m *mockTowerRepo) GetGems(_ uint32) (string, error) { return m.gems, m.gemsErr } +func (m *mockTowerRepo) GetGems(_ uint32) (string, error) { return m.gems, m.gemsErr } func (m *mockTowerRepo) UpdateGems(_ uint32, gems string) error { m.updatedGems = gems return nil @@ -1003,21 +1002,21 @@ func (m *mockTowerRepo) DonateGuildTowerRP(_ uint32, rp uint16) error { // --- mockFestaRepo --- type mockFestaRepo struct { - events []FestaEvent - eventsErr error - teamSouls uint32 - teamErr error - trials []FestaTrial - trialsErr error - topGuild FestaGuildRanking - topErr error - topWindow FestaGuildRanking - topWinErr error - charSouls uint32 - charErr error - hasClaimed bool - prizes []Prize - prizesErr error + events []FestaEvent + eventsErr error + teamSouls uint32 + teamErr error + trials []FestaTrial + trialsErr error + topGuild FestaGuildRanking + topErr error + topWindow FestaGuildRanking + topWinErr error + charSouls uint32 + charErr error + hasClaimed bool + prizes []Prize + prizesErr error cleanupErr error cleanupCalled bool @@ -1035,8 +1034,8 @@ func (m *mockFestaRepo) InsertEvent(start uint32) error { m.insertedStart = start return m.insertErr } -func (m *mockFestaRepo) GetFestaEvents() ([]FestaEvent, error) { return m.events, m.eventsErr } -func (m *mockFestaRepo) GetTeamSouls(_ string) (uint32, error) { return m.teamSouls, m.teamErr } +func (m *mockFestaRepo) GetFestaEvents() ([]FestaEvent, error) { return m.events, m.eventsErr } +func (m *mockFestaRepo) GetTeamSouls(_ string) (uint32, error) { return m.teamSouls, m.teamErr } func (m *mockFestaRepo) GetTrialsWithMonopoly() ([]FestaTrial, error) { return m.trials, m.trialsErr } @@ -1046,15 +1045,15 @@ func (m *mockFestaRepo) GetTopGuildForTrial(_ uint16) (FestaGuildRanking, error) func (m *mockFestaRepo) GetTopGuildInWindow(_, _ uint32) (FestaGuildRanking, error) { return m.topWindow, m.topWinErr } -func (m *mockFestaRepo) GetCharSouls(_ uint32) (uint32, error) { return m.charSouls, m.charErr } -func (m *mockFestaRepo) HasClaimedMainPrize(_ uint32) bool { return m.hasClaimed } -func (m *mockFestaRepo) VoteTrial(_ uint32, _ uint32) error { return nil } -func (m *mockFestaRepo) RegisterGuild(_ uint32, _ string) error { return nil } +func (m *mockFestaRepo) GetCharSouls(_ uint32) (uint32, error) { return m.charSouls, m.charErr } +func (m *mockFestaRepo) HasClaimedMainPrize(_ uint32) bool { return m.hasClaimed } +func (m *mockFestaRepo) VoteTrial(_ uint32, _ uint32) error { return nil } +func (m *mockFestaRepo) RegisterGuild(_ uint32, _ string) error { return nil } func (m *mockFestaRepo) SubmitSouls(_, _ uint32, souls []uint16) error { m.submittedSouls = souls return m.submitErr } -func (m *mockFestaRepo) ClaimPrize(_ uint32, _ uint32) error { return nil } +func (m *mockFestaRepo) ClaimPrize(_ uint32, _ uint32) error { return nil } func (m *mockFestaRepo) ListPrizes(_ uint32, _ string) ([]Prize, error) { return m.prizes, m.prizesErr } @@ -1078,9 +1077,9 @@ type mockDivaRepo struct { eventsErr error } -func (m *mockDivaRepo) DeleteEvents() error { return nil } -func (m *mockDivaRepo) InsertEvent(_ uint32) error { return nil } -func (m *mockDivaRepo) GetEvents() ([]DivaEvent, error) { return m.events, m.eventsErr } +func (m *mockDivaRepo) DeleteEvents() error { return nil } +func (m *mockDivaRepo) InsertEvent(_ uint32) error { return nil } +func (m *mockDivaRepo) GetEvents() ([]DivaEvent, error) { return m.events, m.eventsErr } // --- mockEventRepo --- @@ -1122,20 +1121,20 @@ func (m *mockMiscRepo) UpsertTrendWeapon(_ uint16, _ uint8) error { return nil } // --- mockMercenaryRepo --- type mockMercenaryRepo struct { - nextRastaID uint32 - rastaIDErr error - nextAirouID uint32 - airouIDErr error - loans []MercenaryLoan - loansErr error - catUsages []GuildHuntCatUsage - catUsagesErr error - guildAirou [][]byte + nextRastaID uint32 + rastaIDErr error + nextAirouID uint32 + airouIDErr error + loans []MercenaryLoan + loansErr error + catUsages []GuildHuntCatUsage + catUsagesErr error + guildAirou [][]byte guildAirouErr error } -func (m *mockMercenaryRepo) NextRastaID() (uint32, error) { return m.nextRastaID, m.rastaIDErr } -func (m *mockMercenaryRepo) NextAirouID() (uint32, error) { return m.nextAirouID, m.airouIDErr } +func (m *mockMercenaryRepo) NextRastaID() (uint32, error) { return m.nextRastaID, m.rastaIDErr } +func (m *mockMercenaryRepo) NextAirouID() (uint32, error) { return m.nextAirouID, m.airouIDErr } func (m *mockMercenaryRepo) GetMercenaryLoans(_ uint32) ([]MercenaryLoan, error) { return m.loans, m.loansErr } @@ -1149,17 +1148,17 @@ func (m *mockMercenaryRepo) GetGuildAirou(_ uint32) ([][]byte, error) { // --- mockCafeRepo --- type mockCafeRepo struct { - bonuses []CafeBonus - bonusesErr error - claimable []CafeBonus - claimableErr error + bonuses []CafeBonus + bonusesErr error + claimable []CafeBonus + claimableErr error bonusItemType uint32 bonusItemQty uint32 bonusItemErr error } -func (m *mockCafeRepo) ResetAccepted(_ uint32) error { return nil } -func (m *mockCafeRepo) GetBonuses(_ uint32) ([]CafeBonus, error) { return m.bonuses, m.bonusesErr } +func (m *mockCafeRepo) ResetAccepted(_ uint32) error { return nil } +func (m *mockCafeRepo) GetBonuses(_ uint32) ([]CafeBonus, error) { return m.bonuses, m.bonusesErr } func (m *mockCafeRepo) GetClaimable(_ uint32, _ int64) ([]CafeBonus, error) { return m.claimable, m.claimableErr } diff --git a/server/channelserver/repo_rengoku.go b/server/channelserver/repo_rengoku.go index 4020667b4..b38fcd3b6 100644 --- a/server/channelserver/repo_rengoku.go +++ b/server/channelserver/repo_rengoku.go @@ -3,16 +3,16 @@ package channelserver import ( "fmt" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // RengokuRepository centralizes all database access for the rengoku_score table. type RengokuRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewRengokuRepository creates a new RengokuRepository. -func NewRengokuRepository(db *sqlx.DB) *RengokuRepository { +func NewRengokuRepository(db *dbutil.DB) *RengokuRepository { return &RengokuRepository{db: db} } diff --git a/server/channelserver/repo_rengoku_test.go b/server/channelserver/repo_rengoku_test.go index 3a8c377e3..a1be17a6e 100644 --- a/server/channelserver/repo_rengoku_test.go +++ b/server/channelserver/repo_rengoku_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -12,7 +13,7 @@ func setupRengokuRepo(t *testing.T) (*RengokuRepository, *sqlx.DB, uint32, uint3 userID := CreateTestUser(t, db, "rengoku_test_user") charID := CreateTestCharacter(t, db, userID, "RengokuChar") guildID := CreateTestGuild(t, db, charID, "RengokuGuild") - repo := NewRengokuRepository(db) + repo := NewRengokuRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, charID, guildID } diff --git a/server/channelserver/repo_scenario.go b/server/channelserver/repo_scenario.go index 242e98f6d..967c3fff0 100644 --- a/server/channelserver/repo_scenario.go +++ b/server/channelserver/repo_scenario.go @@ -3,16 +3,16 @@ package channelserver import ( "fmt" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // ScenarioRepository centralizes all database access for the scenario_counter table. type ScenarioRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewScenarioRepository creates a new ScenarioRepository. -func NewScenarioRepository(db *sqlx.DB) *ScenarioRepository { +func NewScenarioRepository(db *dbutil.DB) *ScenarioRepository { return &ScenarioRepository{db: db} } diff --git a/server/channelserver/repo_scenario_test.go b/server/channelserver/repo_scenario_test.go index f27694b51..17e43d518 100644 --- a/server/channelserver/repo_scenario_test.go +++ b/server/channelserver/repo_scenario_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -9,7 +10,7 @@ import ( func setupScenarioRepo(t *testing.T) (*ScenarioRepository, *sqlx.DB) { t.Helper() db := SetupTestDB(t) - repo := NewScenarioRepository(db) + repo := NewScenarioRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db } diff --git a/server/channelserver/repo_session.go b/server/channelserver/repo_session.go index bb8a0dc6e..de9b9ba09 100644 --- a/server/channelserver/repo_session.go +++ b/server/channelserver/repo_session.go @@ -1,16 +1,16 @@ package channelserver import ( - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // SessionRepository centralizes all database access for sign_sessions and servers tables. type SessionRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewSessionRepository creates a new SessionRepository. -func NewSessionRepository(db *sqlx.DB) *SessionRepository { +func NewSessionRepository(db *dbutil.DB) *SessionRepository { return &SessionRepository{db: db} } diff --git a/server/channelserver/repo_session_test.go b/server/channelserver/repo_session_test.go index e4d7d78bf..e97dc2171 100644 --- a/server/channelserver/repo_session_test.go +++ b/server/channelserver/repo_session_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -13,7 +14,7 @@ func setupSessionRepo(t *testing.T) (*SessionRepository, *sqlx.DB, uint32, uint3 charID := CreateTestCharacter(t, db, userID, "SessionChar") token := "test_token_12345" sessionID := CreateTestSignSession(t, db, userID, token) - repo := NewSessionRepository(db) + repo := NewSessionRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, userID, charID, sessionID, token } diff --git a/server/channelserver/repo_shop.go b/server/channelserver/repo_shop.go index 04f1c18f1..860762d56 100644 --- a/server/channelserver/repo_shop.go +++ b/server/channelserver/repo_shop.go @@ -1,16 +1,16 @@ package channelserver import ( - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // ShopRepository centralizes all database access for shop-related tables. type ShopRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewShopRepository creates a new ShopRepository. -func NewShopRepository(db *sqlx.DB) *ShopRepository { +func NewShopRepository(db *dbutil.DB) *ShopRepository { return &ShopRepository{db: db} } diff --git a/server/channelserver/repo_shop_test.go b/server/channelserver/repo_shop_test.go index 9a84b1070..1fbc61a40 100644 --- a/server/channelserver/repo_shop_test.go +++ b/server/channelserver/repo_shop_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -11,7 +12,7 @@ func setupShopRepo(t *testing.T) (*ShopRepository, *sqlx.DB, uint32) { db := SetupTestDB(t) userID := CreateTestUser(t, db, "shop_test_user") charID := CreateTestCharacter(t, db, userID, "ShopChar") - repo := NewShopRepository(db) + repo := NewShopRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, charID } diff --git a/server/channelserver/repo_stamp.go b/server/channelserver/repo_stamp.go index 26a9f4d64..595becd44 100644 --- a/server/channelserver/repo_stamp.go +++ b/server/channelserver/repo_stamp.go @@ -4,16 +4,16 @@ import ( "fmt" "time" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // StampRepository centralizes all database access for the stamps table. type StampRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewStampRepository creates a new StampRepository. -func NewStampRepository(db *sqlx.DB) *StampRepository { +func NewStampRepository(db *dbutil.DB) *StampRepository { return &StampRepository{db: db} } diff --git a/server/channelserver/repo_stamp_test.go b/server/channelserver/repo_stamp_test.go index ef0b2e556..c8867e02f 100644 --- a/server/channelserver/repo_stamp_test.go +++ b/server/channelserver/repo_stamp_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "time" @@ -12,7 +13,7 @@ func setupStampRepo(t *testing.T) (*StampRepository, *sqlx.DB, uint32) { db := SetupTestDB(t) userID := CreateTestUser(t, db, "stamp_test_user") charID := CreateTestCharacter(t, db, userID, "StampChar") - repo := NewStampRepository(db) + repo := NewStampRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, charID } diff --git a/server/channelserver/repo_tower.go b/server/channelserver/repo_tower.go index 1e653476e..8194d815e 100644 --- a/server/channelserver/repo_tower.go +++ b/server/channelserver/repo_tower.go @@ -3,17 +3,17 @@ package channelserver import ( "fmt" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // TowerRepository centralizes all database access for tower-related tables // (tower, guilds tower columns, guild_characters tower columns). type TowerRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewTowerRepository creates a new TowerRepository. -func NewTowerRepository(db *sqlx.DB) *TowerRepository { +func NewTowerRepository(db *dbutil.DB) *TowerRepository { return &TowerRepository{db: db} } diff --git a/server/channelserver/repo_tower_test.go b/server/channelserver/repo_tower_test.go index 5c3f2e01a..5405c9dc1 100644 --- a/server/channelserver/repo_tower_test.go +++ b/server/channelserver/repo_tower_test.go @@ -1,6 +1,7 @@ package channelserver import ( + dbutil "erupe-ce/common/db" "testing" "github.com/jmoiron/sqlx" @@ -17,7 +18,7 @@ func setupTowerRepo(t *testing.T) (*TowerRepository, *sqlx.DB, uint32, uint32) { if _, err := db.Exec("INSERT INTO guild_characters (guild_id, character_id) VALUES ($1, $2)", guildID, charID); err != nil { t.Fatalf("Failed to add char to guild: %v", err) } - repo := NewTowerRepository(db) + repo := NewTowerRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, charID, guildID } diff --git a/server/channelserver/repo_user.go b/server/channelserver/repo_user.go index 919f33dd1..5e5b8b9d0 100644 --- a/server/channelserver/repo_user.go +++ b/server/channelserver/repo_user.go @@ -4,16 +4,16 @@ import ( "database/sql" "time" - "github.com/jmoiron/sqlx" + dbutil "erupe-ce/common/db" ) // UserRepository centralizes all database access for the users table. type UserRepository struct { - db *sqlx.DB + db *dbutil.DB } // NewUserRepository creates a new UserRepository. -func NewUserRepository(db *sqlx.DB) *UserRepository { +func NewUserRepository(db *dbutil.DB) *UserRepository { return &UserRepository{db: db} } diff --git a/server/channelserver/repo_user_test.go b/server/channelserver/repo_user_test.go index 5a95e815c..f7f6bdb21 100644 --- a/server/channelserver/repo_user_test.go +++ b/server/channelserver/repo_user_test.go @@ -2,6 +2,7 @@ package channelserver import ( "database/sql" + dbutil "erupe-ce/common/db" "testing" "time" @@ -12,7 +13,7 @@ func setupUserRepo(t *testing.T) (*UserRepository, *sqlx.DB, uint32) { t.Helper() db := SetupTestDB(t) userID := CreateTestUser(t, db, "user_repo_test") - repo := NewUserRepository(db) + repo := NewUserRepository(dbutil.Wrap(db)) t.Cleanup(func() { TeardownTestDB(t, db) }) return repo, db, userID } diff --git a/server/channelserver/session_lifecycle_integration_test.go b/server/channelserver/session_lifecycle_integration_test.go index f00f6864f..b32a3cd7c 100644 --- a/server/channelserver/session_lifecycle_integration_test.go +++ b/server/channelserver/session_lifecycle_integration_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + dbutil "erupe-ce/common/db" "erupe-ce/common/mhfitem" cfg "erupe-ce/config" "erupe-ce/network/clientctx" @@ -597,18 +598,19 @@ func createTestServerWithDB(t *testing.T, db *sqlx.DB) *Server { server.logger = logger // Initialize repositories - server.charRepo = NewCharacterRepository(db) - server.guildRepo = NewGuildRepository(db) - server.userRepo = NewUserRepository(db) - server.gachaRepo = NewGachaRepository(db) - server.houseRepo = NewHouseRepository(db) - server.festaRepo = NewFestaRepository(db) - server.towerRepo = NewTowerRepository(db) - server.rengokuRepo = NewRengokuRepository(db) - server.mailRepo = NewMailRepository(db) - server.stampRepo = NewStampRepository(db) - server.distRepo = NewDistributionRepository(db) - server.sessionRepo = NewSessionRepository(db) + wdb := dbutil.Wrap(db) + server.charRepo = NewCharacterRepository(wdb) + server.guildRepo = NewGuildRepository(wdb) + server.userRepo = NewUserRepository(wdb) + server.gachaRepo = NewGachaRepository(wdb) + server.houseRepo = NewHouseRepository(wdb) + server.festaRepo = NewFestaRepository(wdb) + server.towerRepo = NewTowerRepository(wdb) + server.rengokuRepo = NewRengokuRepository(wdb) + server.mailRepo = NewMailRepository(wdb) + server.stampRepo = NewStampRepository(wdb) + server.distRepo = NewDistributionRepository(wdb) + server.sessionRepo = NewSessionRepository(wdb) return server } diff --git a/server/channelserver/sys_channel_server.go b/server/channelserver/sys_channel_server.go index b569d5dce..b419b4a17 100644 --- a/server/channelserver/sys_channel_server.go +++ b/server/channelserver/sys_channel_server.go @@ -11,6 +11,7 @@ import ( "time" "erupe-ce/common/byteframe" + dbutil "erupe-ce/common/db" cfg "erupe-ce/config" "erupe-ce/network" "erupe-ce/network/binpacket" @@ -142,27 +143,28 @@ func NewServer(config *Config) *Server { handlerTable: buildHandlerTable(), } - s.charRepo = NewCharacterRepository(config.DB) - s.guildRepo = NewGuildRepository(config.DB) - s.userRepo = NewUserRepository(config.DB) - s.gachaRepo = NewGachaRepository(config.DB) - s.houseRepo = NewHouseRepository(config.DB) - s.festaRepo = NewFestaRepository(config.DB) - s.towerRepo = NewTowerRepository(config.DB) - s.rengokuRepo = NewRengokuRepository(config.DB) - s.mailRepo = NewMailRepository(config.DB) - s.stampRepo = NewStampRepository(config.DB) - s.distRepo = NewDistributionRepository(config.DB) - s.sessionRepo = NewSessionRepository(config.DB) - s.eventRepo = NewEventRepository(config.DB) - s.achievementRepo = NewAchievementRepository(config.DB) - s.shopRepo = NewShopRepository(config.DB) - s.cafeRepo = NewCafeRepository(config.DB) - s.goocooRepo = NewGoocooRepository(config.DB) - s.divaRepo = NewDivaRepository(config.DB) - s.miscRepo = NewMiscRepository(config.DB) - s.scenarioRepo = NewScenarioRepository(config.DB) - s.mercenaryRepo = NewMercenaryRepository(config.DB) + wdb := dbutil.Wrap(config.DB) + s.charRepo = NewCharacterRepository(wdb) + s.guildRepo = NewGuildRepository(wdb) + s.userRepo = NewUserRepository(wdb) + s.gachaRepo = NewGachaRepository(wdb) + s.houseRepo = NewHouseRepository(wdb) + s.festaRepo = NewFestaRepository(wdb) + s.towerRepo = NewTowerRepository(wdb) + s.rengokuRepo = NewRengokuRepository(wdb) + s.mailRepo = NewMailRepository(wdb) + s.stampRepo = NewStampRepository(wdb) + s.distRepo = NewDistributionRepository(wdb) + s.sessionRepo = NewSessionRepository(wdb) + s.eventRepo = NewEventRepository(wdb) + s.achievementRepo = NewAchievementRepository(wdb) + s.shopRepo = NewShopRepository(wdb) + s.cafeRepo = NewCafeRepository(wdb) + s.goocooRepo = NewGoocooRepository(wdb) + s.divaRepo = NewDivaRepository(wdb) + s.miscRepo = NewMiscRepository(wdb) + s.scenarioRepo = NewScenarioRepository(wdb) + s.mercenaryRepo = NewMercenaryRepository(wdb) s.mailService = NewMailService(s.mailRepo, s.guildRepo, s.logger) s.guildService = NewGuildService(s.guildRepo, s.mailService, s.charRepo, s.logger) diff --git a/server/channelserver/testhelpers_db.go b/server/channelserver/testhelpers_db.go index 5fb6f36ed..45bfaa727 100644 --- a/server/channelserver/testhelpers_db.go +++ b/server/channelserver/testhelpers_db.go @@ -8,6 +8,7 @@ import ( "testing" "time" + dbutil "erupe-ce/common/db" "erupe-ce/server/channelserver/compression/nullcomp" "erupe-ce/server/migrations" "github.com/jmoiron/sqlx" @@ -330,25 +331,26 @@ func CreateTestGachaItem(t *testing.T, db *sqlx.DB, entryID uint32, itemType uin // Use this in integration tests instead of setting s.server.db directly. func SetTestDB(s *Server, db *sqlx.DB) { s.db = db - s.charRepo = NewCharacterRepository(db) - s.guildRepo = NewGuildRepository(db) - s.userRepo = NewUserRepository(db) - s.gachaRepo = NewGachaRepository(db) - s.houseRepo = NewHouseRepository(db) - s.festaRepo = NewFestaRepository(db) - s.towerRepo = NewTowerRepository(db) - s.rengokuRepo = NewRengokuRepository(db) - s.mailRepo = NewMailRepository(db) - s.stampRepo = NewStampRepository(db) - s.distRepo = NewDistributionRepository(db) - s.sessionRepo = NewSessionRepository(db) - s.eventRepo = NewEventRepository(db) - s.achievementRepo = NewAchievementRepository(db) - s.shopRepo = NewShopRepository(db) - s.cafeRepo = NewCafeRepository(db) - s.goocooRepo = NewGoocooRepository(db) - s.divaRepo = NewDivaRepository(db) - s.miscRepo = NewMiscRepository(db) - s.scenarioRepo = NewScenarioRepository(db) - s.mercenaryRepo = NewMercenaryRepository(db) + wdb := dbutil.Wrap(db) + s.charRepo = NewCharacterRepository(wdb) + s.guildRepo = NewGuildRepository(wdb) + s.userRepo = NewUserRepository(wdb) + s.gachaRepo = NewGachaRepository(wdb) + s.houseRepo = NewHouseRepository(wdb) + s.festaRepo = NewFestaRepository(wdb) + s.towerRepo = NewTowerRepository(wdb) + s.rengokuRepo = NewRengokuRepository(wdb) + s.mailRepo = NewMailRepository(wdb) + s.stampRepo = NewStampRepository(wdb) + s.distRepo = NewDistributionRepository(wdb) + s.sessionRepo = NewSessionRepository(wdb) + s.eventRepo = NewEventRepository(wdb) + s.achievementRepo = NewAchievementRepository(wdb) + s.shopRepo = NewShopRepository(wdb) + s.cafeRepo = NewCafeRepository(wdb) + s.goocooRepo = NewGoocooRepository(wdb) + s.divaRepo = NewDivaRepository(wdb) + s.miscRepo = NewMiscRepository(wdb) + s.scenarioRepo = NewScenarioRepository(wdb) + s.mercenaryRepo = NewMercenaryRepository(wdb) } diff --git a/server/discordbot/discord_bot_test.go b/server/discordbot/discord_bot_test.go index 964e19fea..ab04d0516 100644 --- a/server/discordbot/discord_bot_test.go +++ b/server/discordbot/discord_bot_test.go @@ -12,18 +12,18 @@ import ( // mockSession implements the Session interface for testing. type mockSession struct { - openErr error - channelResult *discordgo.Channel - channelErr error - userResults map[string]*discordgo.User - userErr error - messageSentTo string - messageSentContent string - messageErr error - addHandlerCalls int - bulkOverwriteAppID string - bulkOverwriteCommands []*discordgo.ApplicationCommand - bulkOverwriteErr error + openErr error + channelResult *discordgo.Channel + channelErr error + userResults map[string]*discordgo.User + userErr error + messageSentTo string + messageSentContent string + messageErr error + addHandlerCalls int + bulkOverwriteAppID string + bulkOverwriteCommands []*discordgo.ApplicationCommand + bulkOverwriteErr error } func (m *mockSession) Open() error { diff --git a/server/migrations/migrations.go b/server/migrations/migrations.go index 1172f56fa..879dc428c 100644 --- a/server/migrations/migrations.go +++ b/server/migrations/migrations.go @@ -8,6 +8,8 @@ import ( "strconv" "strings" + dbutil "erupe-ce/common/db" + "github.com/jmoiron/sqlx" "go.uber.org/zap" ) @@ -15,6 +17,9 @@ import ( //go:embed sql/*.sql var migrationFS embed.FS +//go:embed sqlite/*.sql +var sqliteMigrationFS embed.FS + //go:embed seed/*.sql var seedFS embed.FS @@ -22,15 +27,17 @@ var seedFS embed.FS // (auto-marks baseline as applied), then runs all pending migrations in order. // Each migration runs in its own transaction. func Migrate(db *sqlx.DB, logger *zap.Logger) (int, error) { - if err := ensureVersionTable(db); err != nil { + sqlite := dbutil.IsSQLite(db) + + if err := ensureVersionTable(db, sqlite); err != nil { return 0, fmt.Errorf("creating schema_version table: %w", err) } - if err := detectExistingDB(db, logger); err != nil { + if err := detectExistingDB(db, logger, sqlite); err != nil { return 0, fmt.Errorf("detecting existing database: %w", err) } - migrations, err := readMigrations() + migrations, err := readMigrations(sqlite) if err != nil { return 0, fmt.Errorf("reading migration files: %w", err) } @@ -46,7 +53,7 @@ func Migrate(db *sqlx.DB, logger *zap.Logger) (int, error) { continue } logger.Info(fmt.Sprintf("Applying migration %04d: %s", m.version, m.filename)) - if err := applyMigration(db, m); err != nil { + if err := applyMigration(db, m, sqlite); err != nil { return count, fmt.Errorf("applying %s: %w", m.filename, err) } count++ @@ -58,6 +65,7 @@ func Migrate(db *sqlx.DB, logger *zap.Logger) (int, error) { // ApplySeedData runs all seed/*.sql files. Not tracked in schema_version. // Safe to run multiple times if seed files use ON CONFLICT DO NOTHING. func ApplySeedData(db *sqlx.DB, logger *zap.Logger) (int, error) { + sqlite := dbutil.IsSQLite(db) files, err := fs.ReadDir(seedFS, "seed") if err != nil { return 0, fmt.Errorf("reading seed directory: %w", err) @@ -78,7 +86,11 @@ func ApplySeedData(db *sqlx.DB, logger *zap.Logger) (int, error) { return count, fmt.Errorf("reading seed file %s: %w", name, err) } logger.Info(fmt.Sprintf("Applying seed data: %s", name)) - if _, err := db.Exec(string(data)); err != nil { + sql := string(data) + if sqlite { + sql = dbutil.Adapt(db, sql) + } + if _, err := db.Exec(sql); err != nil { return count, fmt.Errorf("executing seed file %s: %w", name, err) } count++ @@ -88,20 +100,30 @@ func ApplySeedData(db *sqlx.DB, logger *zap.Logger) (int, error) { // Version returns the highest applied migration number, or 0 if none. func Version(db *sqlx.DB) (int, error) { + sqlite := dbutil.IsSQLite(db) + var exists bool - err := db.QueryRow(`SELECT EXISTS( - SELECT 1 FROM information_schema.tables - WHERE table_schema = 'public' AND table_name = 'schema_version' - )`).Scan(&exists) - if err != nil { - return 0, err + if sqlite { + err := db.QueryRow(`SELECT COUNT(*) > 0 FROM sqlite_master + WHERE type='table' AND name='schema_version'`).Scan(&exists) + if err != nil { + return 0, err + } + } else { + err := db.QueryRow(`SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'schema_version' + )`).Scan(&exists) + if err != nil { + return 0, err + } } if !exists { return 0, nil } var version int - err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_version").Scan(&version) + err := db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_version").Scan(&version) return version, err } @@ -111,18 +133,26 @@ type migration struct { sql string } -func ensureVersionTable(db *sqlx.DB) error { - _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version ( +func ensureVersionTable(db *sqlx.DB, sqlite bool) error { + q := `CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY, filename TEXT NOT NULL, applied_at TIMESTAMPTZ DEFAULT now() - )`) + )` + if sqlite { + q = `CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + filename TEXT NOT NULL, + applied_at TEXT DEFAULT CURRENT_TIMESTAMP + )` + } + _, err := db.Exec(q) return err } // detectExistingDB checks if the database has tables but no schema_version rows. // If so, it marks the baseline migration (version 1) as already applied. -func detectExistingDB(db *sqlx.DB, logger *zap.Logger) error { +func detectExistingDB(db *sqlx.DB, logger *zap.Logger, sqlite bool) error { var count int if err := db.QueryRow("SELECT COUNT(*) FROM schema_version").Scan(&count); err != nil { return err @@ -133,10 +163,18 @@ func detectExistingDB(db *sqlx.DB, logger *zap.Logger) error { // Check if the database has any user tables (beyond schema_version itself) var tableCount int - err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables - WHERE table_schema = 'public' AND table_name != 'schema_version'`).Scan(&tableCount) - if err != nil { - return err + if sqlite { + err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master + WHERE type='table' AND name != 'schema_version'`).Scan(&tableCount) + if err != nil { + return err + } + } else { + err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'public' AND table_name != 'schema_version'`).Scan(&tableCount) + if err != nil { + return err + } } if tableCount == 0 { return nil // Fresh database @@ -144,12 +182,22 @@ func detectExistingDB(db *sqlx.DB, logger *zap.Logger) error { // Existing database without migration tracking — mark baseline as applied logger.Info("Detected existing database without schema_version tracking, marking baseline as applied") - _, err = db.Exec("INSERT INTO schema_version (version, filename) VALUES (1, '0001_init.sql')") + _, err := db.Exec("INSERT INTO schema_version (version, filename) VALUES (1, '0001_init.sql')") return err } -func readMigrations() ([]migration, error) { - files, err := fs.ReadDir(migrationFS, "sql") +func readMigrations(sqlite bool) ([]migration, error) { + var embedFS embed.FS + var dir string + if sqlite { + embedFS = sqliteMigrationFS + dir = "sqlite" + } else { + embedFS = migrationFS + dir = "sql" + } + + files, err := fs.ReadDir(embedFS, dir) if err != nil { return nil, err } @@ -163,7 +211,7 @@ func readMigrations() ([]migration, error) { if err != nil { return nil, fmt.Errorf("parsing version from %s: %w", f.Name(), err) } - data, err := migrationFS.ReadFile("sql/" + f.Name()) + data, err := embedFS.ReadFile(dir + "/" + f.Name()) if err != nil { return nil, err } @@ -206,7 +254,7 @@ func appliedVersions(db *sqlx.DB) (map[int]bool, error) { return applied, rows.Err() } -func applyMigration(db *sqlx.DB, m migration) error { +func applyMigration(db *sqlx.DB, m migration, sqlite bool) error { tx, err := db.Begin() if err != nil { return err @@ -217,10 +265,11 @@ func applyMigration(db *sqlx.DB, m migration) error { return err } - if _, err := tx.Exec( - "INSERT INTO schema_version (version, filename) VALUES ($1, $2)", - m.version, m.filename, - ); err != nil { + insertQ := "INSERT INTO schema_version (version, filename) VALUES ($1, $2)" + if sqlite { + insertQ = "INSERT INTO schema_version (version, filename) VALUES (?, ?)" + } + if _, err := tx.Exec(insertQ, m.version, m.filename); err != nil { _ = tx.Rollback() return err } diff --git a/server/migrations/migrations_test.go b/server/migrations/migrations_test.go index 37f3a45b9..ee2667ef5 100644 --- a/server/migrations/migrations_test.go +++ b/server/migrations/migrations_test.go @@ -8,6 +8,7 @@ import ( "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "go.uber.org/zap" + _ "modernc.org/sqlite" ) func testDB(t *testing.T) *sqlx.DB { @@ -194,7 +195,7 @@ func TestParseVersion(t *testing.T) { } func TestReadMigrations(t *testing.T) { - migrations, err := readMigrations() + migrations, err := readMigrations(false) if err != nil { t.Fatalf("readMigrations failed: %v", err) } @@ -210,7 +211,7 @@ func TestReadMigrations(t *testing.T) { } func TestReadMigrations_Sorted(t *testing.T) { - migrations, err := readMigrations() + migrations, err := readMigrations(false) if err != nil { t.Fatalf("readMigrations failed: %v", err) } @@ -223,7 +224,7 @@ func TestReadMigrations_Sorted(t *testing.T) { } func TestReadMigrations_AllHaveSQL(t *testing.T) { - migrations, err := readMigrations() + migrations, err := readMigrations(false) if err != nil { t.Fatalf("readMigrations failed: %v", err) } @@ -235,7 +236,7 @@ func TestReadMigrations_AllHaveSQL(t *testing.T) { } func TestReadMigrations_BaselineIsLargest(t *testing.T) { - migrations, err := readMigrations() + migrations, err := readMigrations(false) if err != nil { t.Fatalf("readMigrations failed: %v", err) } @@ -252,6 +253,61 @@ func TestReadMigrations_BaselineIsLargest(t *testing.T) { } } +func TestReadMigrations_SQLite(t *testing.T) { + migrations, err := readMigrations(true) + if err != nil { + t.Fatalf("readMigrations(sqlite) failed: %v", err) + } + if len(migrations) != 5 { + t.Fatalf("expected 5 SQLite migrations, got %d", len(migrations)) + } + if migrations[0].filename != "0001_init.sql" { + t.Errorf("first SQLite migration = %q, want 0001_init.sql", migrations[0].filename) + } + for _, m := range migrations { + if m.sql == "" { + t.Errorf("SQLite migration %s has empty SQL", m.filename) + } + } +} + +func TestSQLiteMigrateInMemory(t *testing.T) { + db, err := sqlx.Open("sqlite", ":memory:?_pragma=foreign_keys(1)") + if err != nil { + t.Fatalf("Failed to open in-memory SQLite: %v", err) + } + defer func() { _ = db.Close() }() + + logger, _ := zap.NewDevelopment() + applied, err := Migrate(db, logger) + if err != nil { + t.Fatalf("Migrate to SQLite failed: %v", err) + } + if applied != 5 { + t.Errorf("expected 5 migrations applied, got %d", applied) + } + + ver, err := Version(db) + if err != nil { + t.Fatalf("Version failed: %v", err) + } + if ver != 5 { + t.Errorf("expected version 5, got %d", ver) + } + + // Verify a few key tables exist + for _, table := range []string{"users", "characters", "guilds", "guild_characters", "sign_sessions"} { + var count int + err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&count) + if err != nil { + t.Errorf("Failed to check table %s: %v", table, err) + } + if count != 1 { + t.Errorf("Table %s not found in SQLite schema", table) + } + } +} + func TestParseVersion_Comprehensive(t *testing.T) { tests := []struct { filename string diff --git a/server/migrations/sqlite/0001_init.sql b/server/migrations/sqlite/0001_init.sql new file mode 100644 index 000000000..e3624ef61 --- /dev/null +++ b/server/migrations/sqlite/0001_init.sql @@ -0,0 +1,855 @@ +-- Erupe consolidated database schema (SQLite) +-- Translated from PostgreSQL 0001_init.sql +-- Compatible with modernc.org/sqlite +-- +-- Includes: init.sql (v9.1.0) + 9.2-update.sql + all 33 patch schemas + +PRAGMA journal_mode=WAL; +PRAGMA foreign_keys=ON; + + +-- +-- Name: achievements; Type: TABLE +-- + +CREATE TABLE achievements ( + id integer NOT NULL, + ach0 integer DEFAULT 0, + ach1 integer DEFAULT 0, + ach2 integer DEFAULT 0, + ach3 integer DEFAULT 0, + ach4 integer DEFAULT 0, + ach5 integer DEFAULT 0, + ach6 integer DEFAULT 0, + ach7 integer DEFAULT 0, + ach8 integer DEFAULT 0, + ach9 integer DEFAULT 0, + ach10 integer DEFAULT 0, + ach11 integer DEFAULT 0, + ach12 integer DEFAULT 0, + ach13 integer DEFAULT 0, + ach14 integer DEFAULT 0, + ach15 integer DEFAULT 0, + ach16 integer DEFAULT 0, + ach17 integer DEFAULT 0, + ach18 integer DEFAULT 0, + ach19 integer DEFAULT 0, + ach20 integer DEFAULT 0, + ach21 integer DEFAULT 0, + ach22 integer DEFAULT 0, + ach23 integer DEFAULT 0, + ach24 integer DEFAULT 0, + ach25 integer DEFAULT 0, + ach26 integer DEFAULT 0, + ach27 integer DEFAULT 0, + ach28 integer DEFAULT 0, + ach29 integer DEFAULT 0, + ach30 integer DEFAULT 0, + ach31 integer DEFAULT 0, + ach32 integer DEFAULT 0, + PRIMARY KEY (id) +); + + +-- +-- Name: bans; Type: TABLE +-- + +CREATE TABLE bans ( + user_id integer NOT NULL, + expires TEXT, + PRIMARY KEY (user_id) +); + + +-- +-- Name: cafe_accepted; Type: TABLE +-- + +CREATE TABLE cafe_accepted ( + cafe_id integer NOT NULL, + character_id integer NOT NULL +); + + +-- +-- Name: cafebonus; Type: TABLE +-- + +CREATE TABLE cafebonus ( + id INTEGER PRIMARY KEY, + time_req integer NOT NULL, + item_type integer NOT NULL, + item_id integer NOT NULL, + quantity integer NOT NULL +); + + +-- +-- Name: characters; Type: TABLE +-- + +CREATE TABLE characters ( + id INTEGER PRIMARY KEY, + user_id bigint, + is_female boolean, + is_new_character boolean, + name TEXT, + unk_desc_string TEXT, + gr INTEGER, + hr INTEGER, + weapon_type INTEGER, + last_login integer, + savedata BLOB, + decomyset BLOB, + hunternavi BLOB, + otomoairou BLOB, + partner BLOB, + platebox BLOB, + platedata BLOB, + platemyset BLOB, + rengokudata BLOB, + savemercenary BLOB, + restrict_guild_scout boolean DEFAULT false NOT NULL, + gacha_items BLOB, + daily_time TEXT, + house_info BLOB, + login_boost BLOB, + skin_hist BLOB, + kouryou_point integer, + gcp integer, + guild_post_checked TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + time_played integer DEFAULT 0 NOT NULL, + weapon_id integer DEFAULT 0 NOT NULL, + scenariodata BLOB, + savefavoritequest BLOB, + friends text DEFAULT '' NOT NULL, + blocked text DEFAULT '' NOT NULL, + deleted boolean DEFAULT false NOT NULL, + cafe_time integer DEFAULT 0, + netcafe_points integer DEFAULT 0, + boost_time TEXT, + cafe_reset TEXT, + bonus_quests integer DEFAULT 0 NOT NULL, + daily_quests integer DEFAULT 0 NOT NULL, + promo_points integer DEFAULT 0 NOT NULL, + rasta_id integer, + pact_id integer, + stampcard integer DEFAULT 0 NOT NULL, + mezfes BLOB, + FOREIGN KEY (user_id) REFERENCES users(id) +); + + +-- +-- Name: distribution; Type: TABLE +-- + +CREATE TABLE distribution ( + id INTEGER PRIMARY KEY, + character_id integer, + type integer NOT NULL, + deadline TEXT, + event_name text DEFAULT 'GM Gift!' NOT NULL, + description text DEFAULT '~C05You received a gift!' NOT NULL, + times_acceptable integer DEFAULT 1 NOT NULL, + min_hr integer, + max_hr integer, + min_sr integer, + max_sr integer, + min_gr integer, + max_gr integer, + rights integer, + selection boolean +); + + +-- +-- Name: distribution_items; Type: TABLE +-- + +CREATE TABLE distribution_items ( + id INTEGER PRIMARY KEY, + distribution_id integer NOT NULL, + item_type integer NOT NULL, + item_id integer, + quantity integer +); + + +-- +-- Name: distributions_accepted; Type: TABLE +-- + +CREATE TABLE distributions_accepted ( + distribution_id integer, + character_id integer +); + + +-- +-- Name: event_quests; Type: TABLE +-- + +CREATE TABLE event_quests ( + id INTEGER PRIMARY KEY, + max_players integer, + quest_type integer NOT NULL, + quest_id integer NOT NULL, + mark integer, + flags integer, + start_time TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + active_days integer, + inactive_days integer +); + + +-- +-- Name: events; Type: TABLE +-- + +CREATE TABLE events ( + id INTEGER PRIMARY KEY, + event_type TEXT NOT NULL CHECK (event_type IN ('festa','diva','vs','mezfes')), + start_time TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: feature_weapon; Type: TABLE +-- + +CREATE TABLE feature_weapon ( + start_time TEXT NOT NULL, + featured integer NOT NULL +); + + +-- +-- Name: festa_prizes; Type: TABLE +-- + +CREATE TABLE festa_prizes ( + id INTEGER PRIMARY KEY, + type TEXT NOT NULL CHECK (type IN ('personal','guild')), + tier integer NOT NULL, + souls_req integer NOT NULL, + item_id integer NOT NULL, + num_item integer NOT NULL +); + + +-- +-- Name: festa_prizes_accepted; Type: TABLE +-- + +CREATE TABLE festa_prizes_accepted ( + prize_id integer NOT NULL, + character_id integer NOT NULL +); + + +-- +-- Name: festa_registrations; Type: TABLE +-- + +CREATE TABLE festa_registrations ( + guild_id integer NOT NULL, + team TEXT NOT NULL CHECK (team IN ('none','red','blue')) +); + + +-- +-- Name: festa_submissions; Type: TABLE +-- + +CREATE TABLE festa_submissions ( + character_id integer NOT NULL, + guild_id integer NOT NULL, + trial_type integer NOT NULL, + souls integer NOT NULL, + "timestamp" TEXT NOT NULL +); + + +-- +-- Name: festa_trials; Type: TABLE +-- + +CREATE TABLE festa_trials ( + id INTEGER PRIMARY KEY, + objective integer NOT NULL, + goal_id integer NOT NULL, + times_req integer NOT NULL, + locale_req integer DEFAULT 0 NOT NULL, + reward integer NOT NULL +); + + +-- +-- Name: fpoint_items; Type: TABLE +-- + +CREATE TABLE fpoint_items ( + id INTEGER PRIMARY KEY, + item_type integer NOT NULL, + item_id integer NOT NULL, + quantity integer NOT NULL, + fpoints integer NOT NULL, + buyable boolean DEFAULT false NOT NULL +); + + +-- +-- Name: gacha_box; Type: TABLE +-- + +CREATE TABLE gacha_box ( + gacha_id integer, + entry_id integer, + character_id integer +); + + +-- +-- Name: gacha_entries; Type: TABLE +-- + +CREATE TABLE gacha_entries ( + id INTEGER PRIMARY KEY, + gacha_id integer, + entry_type integer, + item_type integer, + item_number integer, + item_quantity integer, + weight integer, + rarity integer, + rolls integer, + frontier_points integer, + daily_limit integer, + name text +); + + +-- +-- Name: gacha_items; Type: TABLE +-- + +CREATE TABLE gacha_items ( + id INTEGER PRIMARY KEY, + entry_id integer, + item_type integer, + item_id integer, + quantity integer +); + + +-- +-- Name: gacha_shop; Type: TABLE +-- + +CREATE TABLE gacha_shop ( + id INTEGER PRIMARY KEY, + min_gr integer, + min_hr integer, + name text, + url_banner text, + url_feature text, + url_thumbnail text, + wide boolean, + recommended boolean, + gacha_type integer, + hidden boolean +); + + +-- +-- Name: gacha_stepup; Type: TABLE +-- + +CREATE TABLE gacha_stepup ( + gacha_id integer, + step integer, + character_id integer, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); + + +-- +-- Name: goocoo; Type: TABLE +-- + +CREATE TABLE goocoo ( + id INTEGER PRIMARY KEY, + goocoo0 BLOB, + goocoo1 BLOB, + goocoo2 BLOB, + goocoo3 BLOB, + goocoo4 BLOB +); + + +-- +-- Name: guild_adventures; Type: TABLE +-- + +CREATE TABLE guild_adventures ( + id INTEGER PRIMARY KEY, + guild_id integer NOT NULL, + destination integer NOT NULL, + charge integer DEFAULT 0 NOT NULL, + depart integer NOT NULL, + "return" integer NOT NULL, + collected_by text DEFAULT '' NOT NULL +); + + +-- +-- Name: guild_alliances; Type: TABLE +-- + +CREATE TABLE guild_alliances ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + parent_id integer NOT NULL, + sub1_id integer, + sub2_id integer +); + + +-- +-- Name: guild_applications; Type: TABLE +-- + +CREATE TABLE guild_applications ( + id INTEGER PRIMARY KEY, + guild_id integer NOT NULL, + character_id integer NOT NULL, + actor_id integer NOT NULL, + application_type TEXT NOT NULL CHECK (application_type IN ('applied','invited')), + created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + UNIQUE (guild_id, character_id), + FOREIGN KEY (actor_id) REFERENCES characters(id), + FOREIGN KEY (character_id) REFERENCES characters(id), + FOREIGN KEY (guild_id) REFERENCES guilds(id) +); + + +-- +-- Name: guild_characters; Type: TABLE +-- + +CREATE TABLE guild_characters ( + id INTEGER PRIMARY KEY, + guild_id bigint, + character_id bigint, + joined_at TEXT DEFAULT CURRENT_TIMESTAMP, + avoid_leadership boolean DEFAULT false NOT NULL, + order_index integer DEFAULT 1 NOT NULL, + recruiter boolean DEFAULT false NOT NULL, + rp_today integer DEFAULT 0, + rp_yesterday integer DEFAULT 0, + tower_mission_1 integer, + tower_mission_2 integer, + tower_mission_3 integer, + box_claimed TEXT DEFAULT CURRENT_TIMESTAMP, + treasure_hunt integer, + trial_vote integer, + FOREIGN KEY (character_id) REFERENCES characters(id), + FOREIGN KEY (guild_id) REFERENCES guilds(id) +); + + +-- +-- Name: guild_hunts; Type: TABLE +-- + +CREATE TABLE guild_hunts ( + id INTEGER PRIMARY KEY, + guild_id integer NOT NULL, + host_id integer NOT NULL, + destination integer NOT NULL, + level integer NOT NULL, + acquired boolean DEFAULT false NOT NULL, + collected boolean DEFAULT false NOT NULL, + hunt_data BLOB NOT NULL, + cats_used text NOT NULL, + start TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +-- +-- Name: guild_hunts_claimed; Type: TABLE +-- + +CREATE TABLE guild_hunts_claimed ( + hunt_id integer NOT NULL, + character_id integer NOT NULL +); + + +-- +-- Name: guild_meals; Type: TABLE +-- + +CREATE TABLE guild_meals ( + id INTEGER PRIMARY KEY, + guild_id integer NOT NULL, + meal_id integer NOT NULL, + level integer NOT NULL, + created_at TEXT +); + + +-- +-- Name: guild_posts; Type: TABLE +-- + +CREATE TABLE guild_posts ( + id INTEGER PRIMARY KEY, + guild_id integer NOT NULL, + author_id integer NOT NULL, + post_type integer NOT NULL, + stamp_id integer NOT NULL, + title text NOT NULL, + body text NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + liked_by text DEFAULT '' NOT NULL, + deleted boolean DEFAULT false NOT NULL +); + + +-- +-- Name: guilds; Type: TABLE +-- + +CREATE TABLE guilds ( + id INTEGER PRIMARY KEY, + name TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + leader_id integer NOT NULL, + main_motto integer DEFAULT 0, + rank_rp integer DEFAULT 0 NOT NULL, + comment TEXT DEFAULT '' NOT NULL, + icon BLOB, + sub_motto integer DEFAULT 0, + item_box BLOB, + event_rp integer DEFAULT 0 NOT NULL, + pugi_name_1 TEXT DEFAULT '', + pugi_name_2 TEXT DEFAULT '', + pugi_name_3 TEXT DEFAULT '', + recruiting boolean DEFAULT true NOT NULL, + pugi_outfit_1 integer DEFAULT 0 NOT NULL, + pugi_outfit_2 integer DEFAULT 0 NOT NULL, + pugi_outfit_3 integer DEFAULT 0 NOT NULL, + pugi_outfits integer DEFAULT 0 NOT NULL, + tower_mission_page integer DEFAULT 1, + tower_rp integer DEFAULT 0, + room_rp integer DEFAULT 0, + room_expiry TEXT, + weekly_bonus_users integer DEFAULT 0 NOT NULL, + rp_reset_at TEXT +); + + +-- +-- Name: kill_logs; Type: TABLE +-- + +CREATE TABLE kill_logs ( + id INTEGER PRIMARY KEY, + character_id integer NOT NULL, + monster integer NOT NULL, + quantity integer NOT NULL, + "timestamp" TEXT NOT NULL +); + + +-- +-- Name: login_boost; Type: TABLE +-- + +CREATE TABLE login_boost ( + char_id integer, + week_req integer, + expiration TEXT, + reset TEXT +); + + +-- +-- Name: mail; Type: TABLE +-- + +CREATE TABLE mail ( + id INTEGER PRIMARY KEY, + sender_id integer NOT NULL, + recipient_id integer NOT NULL, + subject TEXT DEFAULT '' NOT NULL, + body TEXT DEFAULT '' NOT NULL, + read boolean DEFAULT false NOT NULL, + attached_item_received boolean DEFAULT false NOT NULL, + attached_item integer, + attached_item_amount integer DEFAULT 1 NOT NULL, + is_guild_invite boolean DEFAULT false NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + deleted boolean DEFAULT false NOT NULL, + locked boolean DEFAULT false NOT NULL, + is_sys_message boolean DEFAULT false NOT NULL, + FOREIGN KEY (recipient_id) REFERENCES characters(id), + FOREIGN KEY (sender_id) REFERENCES characters(id) +); + + +-- +-- Name: rengoku_score; Type: TABLE +-- + +CREATE TABLE rengoku_score ( + character_id integer NOT NULL, + max_stages_mp integer, + max_points_mp integer, + max_stages_sp integer, + max_points_sp integer, + PRIMARY KEY (character_id) +); + + +-- +-- Name: scenario_counter; Type: TABLE +-- + +CREATE TABLE scenario_counter ( + id INTEGER PRIMARY KEY, + scenario_id numeric NOT NULL, + category_id numeric NOT NULL +); + + +-- +-- Name: servers; Type: TABLE +-- + +CREATE TABLE servers ( + server_id integer NOT NULL, + current_players integer NOT NULL, + world_name text, + world_description text, + land integer +); + + +-- +-- Name: shop_items; Type: TABLE +-- + +CREATE TABLE shop_items ( + shop_type integer, + shop_id integer, + id INTEGER PRIMARY KEY, + item_id INTEGER, + cost integer, + quantity INTEGER, + min_hr INTEGER, + min_sr INTEGER, + min_gr INTEGER, + store_level INTEGER, + max_quantity INTEGER, + road_floors INTEGER, + road_fatalis INTEGER +); + + +-- +-- Name: shop_items_bought; Type: TABLE +-- + +CREATE TABLE shop_items_bought ( + character_id integer, + shop_item_id integer, + bought integer +); + +CREATE UNIQUE INDEX IF NOT EXISTS shop_items_bought_character_item_unique + ON shop_items_bought (character_id, shop_item_id); + + +-- +-- Name: sign_sessions; Type: TABLE +-- + +CREATE TABLE sign_sessions ( + user_id integer, + char_id integer, + token TEXT NOT NULL, + server_id integer, + id INTEGER PRIMARY KEY, + psn_id text +); + + +-- +-- Name: stamps; Type: TABLE +-- + +CREATE TABLE stamps ( + character_id integer NOT NULL, + hl_total integer DEFAULT 0, + hl_redeemed integer DEFAULT 0, + hl_checked TEXT, + ex_total integer DEFAULT 0, + ex_redeemed integer DEFAULT 0, + ex_checked TEXT, + monthly_claimed TEXT, + monthly_hl_claimed TEXT, + monthly_ex_claimed TEXT, + PRIMARY KEY (character_id) +); + + +-- +-- Name: titles; Type: TABLE +-- + +CREATE TABLE titles ( + id integer NOT NULL, + char_id integer NOT NULL, + unlocked_at TEXT, + updated_at TEXT +); + + +-- +-- Name: tower; Type: TABLE +-- + +CREATE TABLE tower ( + char_id integer, + tr integer, + trp integer, + tsp integer, + block1 integer, + block2 integer, + skills text, + gems text +); + + +-- +-- Name: trend_weapons; Type: TABLE +-- + +CREATE TABLE trend_weapons ( + weapon_id integer NOT NULL, + weapon_type integer NOT NULL, + count integer DEFAULT 0, + PRIMARY KEY (weapon_id) +); + + +-- +-- Name: user_binary; Type: TABLE +-- + +CREATE TABLE user_binary ( + id INTEGER PRIMARY KEY, + house_tier BLOB, + house_state integer, + house_password text, + house_data BLOB, + house_furniture BLOB, + bookshelf BLOB, + gallery BLOB, + tore BLOB, + garden BLOB, + mission BLOB +); + + +-- +-- Name: users; Type: TABLE +-- + +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username text NOT NULL UNIQUE, + password text NOT NULL, + item_box BLOB, + rights integer DEFAULT 12 NOT NULL, + last_character integer DEFAULT 0, + last_login TEXT, + return_expires TEXT, + gacha_premium integer, + gacha_trial integer, + frontier_points integer, + psn_id text, + wiiu_key text, + discord_token text, + discord_id text, + op boolean, + timer boolean +); + + +-- +-- Name: warehouse; Type: TABLE +-- + +CREATE TABLE warehouse ( + character_id integer NOT NULL, + item0 BLOB, + item1 BLOB, + item2 BLOB, + item3 BLOB, + item4 BLOB, + item5 BLOB, + item6 BLOB, + item7 BLOB, + item8 BLOB, + item9 BLOB, + item10 BLOB, + item0name text, + item1name text, + item2name text, + item3name text, + item4name text, + item5name text, + item6name text, + item7name text, + item8name text, + item9name text, + equip0 BLOB, + equip1 BLOB, + equip2 BLOB, + equip3 BLOB, + equip4 BLOB, + equip5 BLOB, + equip6 BLOB, + equip7 BLOB, + equip8 BLOB, + equip9 BLOB, + equip10 BLOB, + equip0name text, + equip1name text, + equip2name text, + equip3name text, + equip4name text, + equip5name text, + equip6name text, + equip7name text, + equip8name text, + equip9name text, + PRIMARY KEY (character_id) +); + + +-- +-- Indexes +-- + +CREATE INDEX guild_application_type_index ON guild_applications (application_type); + +CREATE UNIQUE INDEX guild_character_unique_index ON guild_characters (character_id); + +CREATE INDEX mail_recipient_deleted_created_id_index ON mail (recipient_id, deleted, created_at DESC, id DESC); diff --git a/server/migrations/sqlite/0002_catch_up_patches.sql b/server/migrations/sqlite/0002_catch_up_patches.sql new file mode 100644 index 000000000..938f181ba --- /dev/null +++ b/server/migrations/sqlite/0002_catch_up_patches.sql @@ -0,0 +1,3 @@ +-- SQLite: no-op. The consolidated 0001_init.sql already includes all patch columns. +-- This file exists so the migration version numbering stays in sync with PostgreSQL. +SELECT 1; diff --git a/server/migrations/sqlite/0003_shop_items_bought_unique.sql b/server/migrations/sqlite/0003_shop_items_bought_unique.sql new file mode 100644 index 000000000..35abff9f7 --- /dev/null +++ b/server/migrations/sqlite/0003_shop_items_bought_unique.sql @@ -0,0 +1,2 @@ +-- SQLite: no-op. The unique index is already in 0001_init.sql. +SELECT 1; diff --git a/server/migrations/sqlite/0004_alliance_recruiting.sql b/server/migrations/sqlite/0004_alliance_recruiting.sql new file mode 100644 index 000000000..0c123cb58 --- /dev/null +++ b/server/migrations/sqlite/0004_alliance_recruiting.sql @@ -0,0 +1,2 @@ +-- SQLite: no-op. The recruiting column is already in 0001_init.sql. +SELECT 1; diff --git a/server/migrations/sqlite/0005_distribution_drop_data.sql b/server/migrations/sqlite/0005_distribution_drop_data.sql new file mode 100644 index 000000000..75bdcb878 --- /dev/null +++ b/server/migrations/sqlite/0005_distribution_drop_data.sql @@ -0,0 +1,2 @@ +-- SQLite: no-op. The data column was never included in 0001_init.sql. +SELECT 1; diff --git a/server/setup/handlers.go b/server/setup/handlers.go index 37dbd1cfb..b62a54354 100644 --- a/server/setup/handlers.go +++ b/server/setup/handlers.go @@ -45,6 +45,15 @@ func (ws *wizardServer) handleClientModes(w http.ResponseWriter, _ *http.Request writeJSON(w, http.StatusOK, map[string]interface{}{"modes": clientModes()}) } +func (ws *wizardServer) handleCheckQuests(w http.ResponseWriter, _ *http.Request) { + status := checkQuestFiles("") + writeJSON(w, http.StatusOK, status) +} + +func (ws *wizardServer) handlePresets(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]interface{}{"presets": availablePresets()}) +} + // testDBRequest is the JSON body for POST /api/setup/test-db. type testDBRequest struct { Host string `json:"host"` diff --git a/server/setup/setup.go b/server/setup/setup.go index 51068d96d..f11e08094 100644 --- a/server/setup/setup.go +++ b/server/setup/setup.go @@ -23,6 +23,8 @@ func Run(logger *zap.Logger, port int) error { r.HandleFunc("/api/setup/client-modes", ws.handleClientModes).Methods("GET") r.HandleFunc("/api/setup/test-db", ws.handleTestDB).Methods("POST") r.HandleFunc("/api/setup/init-db", ws.handleInitDB).Methods("POST") + r.HandleFunc("/api/setup/check-quests", ws.handleCheckQuests).Methods("GET") + r.HandleFunc("/api/setup/presets", ws.handlePresets).Methods("GET") r.HandleFunc("/api/setup/finish", ws.handleFinish).Methods("POST") srv := &http.Server{ diff --git a/server/setup/wizard.go b/server/setup/wizard.go index e6410a5a5..40ff6b3cb 100644 --- a/server/setup/wizard.go +++ b/server/setup/wizard.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "os" + "path/filepath" "github.com/lib/pq" ) @@ -31,6 +32,7 @@ type FinishRequest struct { Language string `json:"language"` ClientMode string `json:"clientMode"` AutoCreateAccount bool `json:"autoCreateAccount"` + Preset string `json:"preset"` } // buildDefaultConfig produces a minimal config map with only user-provided values. @@ -40,7 +42,7 @@ func buildDefaultConfig(req FinishRequest) map[string]interface{} { if lang == "" { lang = "jp" } - return map[string]interface{}{ + cfg := map[string]interface{}{ "Host": req.Host, "Language": lang, "ClientMode": req.ClientMode, @@ -53,6 +55,156 @@ func buildDefaultConfig(req FinishRequest) map[string]interface{} { "Database": req.DBName, }, } + + // Apply preset overrides. The "community" preset uses Viper defaults and + // adds nothing to the config file. + if overrides, ok := presetConfigs()[req.Preset]; ok { + for k, v := range overrides { + cfg[k] = v + } + } + + return cfg +} + +// presetConfigs returns config overrides keyed by preset ID. +// The "community" preset is intentionally absent — it relies entirely on +// Viper defaults. +func presetConfigs() map[string]map[string]interface{} { + return map[string]map[string]interface{}{ + "solo": { + "GameplayOptions": map[string]interface{}{ + "HRPMultiplier": 3.0, + "SRPMultiplier": 3.0, + "GRPMultiplier": 3.0, + "GSRPMultiplier": 3.0, + "ZennyMultiplier": 2.0, + "GZennyMultiplier": 2.0, + "MaterialMultiplier": 2.0, + "GMaterialMultiplier": 2.0, + "ExtraCarves": 2, + "GExtraCarves": 2, + }, + "Entrance": map[string]interface{}{ + "Entries": []map[string]interface{}{ + { + "Name": "Solo", + "Type": 1, + "Channels": []map[string]interface{}{ + {"Port": 54001, "MaxPlayers": 100}, + }, + }, + }, + }, + }, + "small": { + "Entrance": map[string]interface{}{ + "Entries": []map[string]interface{}{ + { + "Name": "World 1", + "Type": 1, + "Channels": []map[string]interface{}{ + {"Port": 54001, "MaxPlayers": 100}, + {"Port": 54002, "MaxPlayers": 100}, + }, + }, + }, + }, + }, + "rebalanced": { + "GameplayOptions": map[string]interface{}{ + "HRPMultiplier": 2.0, + "SRPMultiplier": 2.0, + "GRPMultiplier": 2.0, + "GSRPMultiplier": 2.0, + "ExtraCarves": 1, + "GExtraCarves": 1, + }, + "Entrance": map[string]interface{}{ + "Entries": []map[string]interface{}{ + { + "Name": "Normal", + "Type": 1, + "Channels": []map[string]interface{}{ + {"Port": 54001, "MaxPlayers": 100}, + {"Port": 54002, "MaxPlayers": 100}, + }, + }, + { + "Name": "Cities", + "Type": 2, + "Channels": []map[string]interface{}{ + {"Port": 54003, "MaxPlayers": 100}, + {"Port": 54004, "MaxPlayers": 100}, + }, + }, + }, + }, + }, + } +} + +// QuestStatus holds the result of a quest files check. +type QuestStatus struct { + QuestsFound bool `json:"questsFound"` + QuestCount int `json:"questCount"` +} + +// checkQuestFiles checks if quest files exist in the bin/quests/ directory. +func checkQuestFiles(binPath string) QuestStatus { + if binPath == "" { + binPath = "bin" + } + questDir := filepath.Join(binPath, "quests") + entries, err := os.ReadDir(questDir) + if err != nil { + return QuestStatus{QuestsFound: false, QuestCount: 0} + } + count := 0 + for _, e := range entries { + if !e.IsDir() { + count++ + } + } + return QuestStatus{QuestsFound: count > 0, QuestCount: count} +} + +// PresetInfo describes a gameplay preset for the wizard UI. +type PresetInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Channels int `json:"channels"` +} + +// availablePresets returns the list of gameplay presets shown in the wizard. +func availablePresets() []PresetInfo { + return []PresetInfo{ + { + ID: "solo", + Name: "Solo / Testing", + Description: "Single channel, boosted XP rates (3x), relaxed grind. Ideal for solo play or development testing.", + Channels: 1, + }, + { + ID: "small", + Name: "Small Group (2-8 players)", + Description: "Two channels with vanilla rates. Good for friends playing together.", + Channels: 2, + }, + { + ID: "community", + Name: "Community Server", + Description: "Full 8-channel topology with vanilla rates. Ready for a public community.", + Channels: 8, + }, + { + ID: "rebalanced", + Name: "Rebalanced", + Description: "Community-tuned rates: 2x GRP, 2x HRP, extra carves. Addresses G-Rank grind without trivializing content.", + Channels: 4, + }, + } } // writeConfig writes the config map to config.json with pretty formatting. diff --git a/server/setup/wizard.html b/server/setup/wizard.html index be089c012..5a5dc9b03 100644 --- a/server/setup/wizard.html +++ b/server/setup/wizard.html @@ -87,12 +87,16 @@ h1{font-size:1.75rem;margin-bottom:.5rem;color:#e94560;text-align:center}
+
+
1. Database 2. Schema - 3. Server - 4. Finish + 3. Quest Files + 4. Preset + 5. Server + 6. Finish
@@ -134,8 +138,39 @@ h1{font-size:1.75rem;margin-bottom:.5rem;color:#e94560;text-align:center} - + + + + + + + - -