diff --git a/.dockerignore b/.dockerignore
index 97141f1e7a..4c5a7ba910 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -9,4 +9,6 @@ node_modules
src/main/resources/**
!src/main/resources/config/
!src/main/resources/config/application.yml
+!src/main/resources/db/
+!src/main/resources/db/**
!src/main/resources/logback.xml
diff --git a/.github/workflows/deploy-to-production.yml b/.github/workflows/deploy-to-production.yml
index 571230ee98..b9172b600b 100644
--- a/.github/workflows/deploy-to-production.yml
+++ b/.github/workflows/deploy-to-production.yml
@@ -62,6 +62,7 @@ jobs:
REPO_DISPATCH_TOKEN: ${{ secrets.REPOSITORY_DISPATCH_TOKEN }}
TI4_ULTIMATE_STATISTICS_API_KEY: ${{ secrets.TI4_ULTIMATE_STATISTICS_API_KEY }}
MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY: ${{ secrets.MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY }}
+ POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
DISCORD_REDIRECT_URI: ${{ secrets.DISCORD_REDIRECT_URI }}
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
@@ -74,7 +75,7 @@ jobs:
password: ${{ secrets.HOSTINGER_SSH_PASSWORD }}
port: ${{ secrets.HOSTINGER_SSH_PORT }}
command_timeout: 30m
- envs: AWS_KEY, AWS_SECRET, DISCORD_BOT_TOKEN, DISCORD_BOT_USERID, REPO_DISPATCH_TOKEN, TI4_ULTIMATE_STATISTICS_API_KEY, MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY, DISCORD_WEBHOOK_URL, DISCORD_REDIRECT_URI, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, ROLLBAR_ACCESS_TOKEN, ROLLBAR_ENVIRONMENT, ROLLBAR_CODE_VERSION
+ envs: AWS_KEY, AWS_SECRET, DISCORD_BOT_TOKEN, DISCORD_BOT_USERID, REPO_DISPATCH_TOKEN, TI4_ULTIMATE_STATISTICS_API_KEY, MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY, POSTGRES_PASSWORD, DISCORD_WEBHOOK_URL, DISCORD_REDIRECT_URI, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, ROLLBAR_ACCESS_TOKEN, ROLLBAR_ENVIRONMENT, ROLLBAR_CODE_VERSION
script: |
set -eu
diff --git a/.github/workflows/restart-bot.yml b/.github/workflows/restart-bot.yml
index 03a7f1b090..d8508f3a1e 100644
--- a/.github/workflows/restart-bot.yml
+++ b/.github/workflows/restart-bot.yml
@@ -19,6 +19,7 @@ jobs:
REPO_DISPATCH_TOKEN: ${{ secrets.REPOSITORY_DISPATCH_TOKEN }}
TI4_ULTIMATE_STATISTICS_API_KEY: ${{ secrets.TI4_ULTIMATE_STATISTICS_API_KEY }}
MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY: ${{ secrets.MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY }}
+ POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
DISCORD_REDIRECT_URI: ${{ secrets.DISCORD_REDIRECT_URI }}
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
@@ -31,7 +32,7 @@ jobs:
password: ${{ secrets.HOSTINGER_SSH_PASSWORD }}
port: ${{ secrets.HOSTINGER_SSH_PORT }}
command_timeout: 30m
- envs: AWS_KEY, AWS_SECRET, DISCORD_BOT_TOKEN, DISCORD_BOT_USERID, REPO_DISPATCH_TOKEN, TI4_ULTIMATE_STATISTICS_API_KEY, MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY, DISCORD_REDIRECT_URI, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, ROLLBAR_ACCESS_TOKEN, ROLLBAR_ENVIRONMENT, ROLLBAR_CODE_VERSION
+ envs: AWS_KEY, AWS_SECRET, DISCORD_BOT_TOKEN, DISCORD_BOT_USERID, REPO_DISPATCH_TOKEN, TI4_ULTIMATE_STATISTICS_API_KEY, MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY, POSTGRES_PASSWORD, DISCORD_REDIRECT_URI, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, ROLLBAR_ACCESS_TOKEN, ROLLBAR_ENVIRONMENT, ROLLBAR_CODE_VERSION
script: |
set -eu
diff --git a/.github/workflows/start-bot.yml b/.github/workflows/start-bot.yml
index c81da5fa1b..b515638eb5 100644
--- a/.github/workflows/start-bot.yml
+++ b/.github/workflows/start-bot.yml
@@ -19,6 +19,7 @@ jobs:
REPO_DISPATCH_TOKEN: ${{ secrets.REPOSITORY_DISPATCH_TOKEN }}
TI4_ULTIMATE_STATISTICS_API_KEY: ${{ secrets.TI4_ULTIMATE_STATISTICS_API_KEY }}
MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY: ${{ secrets.MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY }}
+ POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
DISCORD_REDIRECT_URI: ${{ secrets.DISCORD_REDIRECT_URI }}
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
@@ -30,7 +31,7 @@ jobs:
username: ${{ secrets.HOSTINGER_SSH_USER }}
password: ${{ secrets.HOSTINGER_SSH_PASSWORD }}
port: ${{ secrets.HOSTINGER_SSH_PORT }}
- envs: AWS_KEY, AWS_SECRET, DISCORD_BOT_TOKEN, DISCORD_BOT_USERID, REPO_DISPATCH_TOKEN, TI4_ULTIMATE_STATISTICS_API_KEY, MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY, DISCORD_REDIRECT_URI, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, ROLLBAR_ACCESS_TOKEN, ROLLBAR_ENVIRONMENT, ROLLBAR_CODE_VERSION
+ envs: AWS_KEY, AWS_SECRET, DISCORD_BOT_TOKEN, DISCORD_BOT_USERID, REPO_DISPATCH_TOKEN, TI4_ULTIMATE_STATISTICS_API_KEY, MANAGEMENT_ENDPOINTS_ACTUATOR_API_KEY, POSTGRES_PASSWORD, DISCORD_REDIRECT_URI, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, ROLLBAR_ACCESS_TOKEN, ROLLBAR_ENVIRONMENT, ROLLBAR_CODE_VERSION
script: |
set -eu
diff --git a/.gitignore b/.gitignore
index a740fac065..e715cc1946 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,7 @@ runlocally clean.ps1
runlocally.ps1
*.tar
*.log
+*.fasl
*test-save.txt
src/test/resources/maps/undo
tibot.db
diff --git a/Dockerfile b/Dockerfile
index e527518318..a194891690 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -10,6 +10,7 @@ RUN --mount=type=cache,target=/root/.m2 \
# add sources late for better layer reuse
# Only package classpath config; runtime assets are mounted from the host repo.
COPY src/main/resources/config ./src/main/resources/config
+COPY src/main/resources/db ./src/main/resources/db
COPY src/main/resources/logback.xml ./src/main/resources/logback.xml
COPY src/main/java ./src/main/java
diff --git a/docker-compose.production.yml b/docker-compose.production.yml
index 3cefa3f6e6..ec3ca27cbb 100644
--- a/docker-compose.production.yml
+++ b/docker-compose.production.yml
@@ -17,6 +17,8 @@ services:
image: ${IMAGE_REPOSITORY:-ghcr.io/asyncti4/ti4-map-generator-bot}:${IMAGE_TAG:-master}
restart: unless-stopped
mem_limit: 7g
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
volumes:
- ${HOST_TI4_SAVES_DIR}:/opt/STORAGE
- ${HOST_TI4_REPO_DIR}/src/main/resources:/opt/resources:ro
@@ -36,6 +38,11 @@ services:
ROLLBAR_ACCESS_TOKEN: ${ROLLBAR_ACCESS_TOKEN}
ROLLBAR_CODE_VERSION: ${ROLLBAR_CODE_VERSION}
ROLLBAR_ENVIRONMENT: ${ROLLBAR_ENVIRONMENT}
+ SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL:-jdbc:postgresql://host.docker.internal:5432/tibot}
+ SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME:-tibot}
+ SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
+ SPRING_FLYWAY_ENABLED: ${SPRING_FLYWAY_ENABLED:-true}
+ SPRING_JPA_HIBERNATE_DDL_AUTO: ${SPRING_JPA_HIBERNATE_DDL_AUTO:-validate}
TI4_ULTIMATE_STATISTICS_API_KEY: ${TI4_ULTIMATE_STATISTICS_API_KEY}
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:8081/api/public/ping >/dev/null 2>&1"]
diff --git a/pom.xml b/pom.xml
index 6ec29e5721..d11b3634d1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,7 +18,6 @@
1.18.46
6.4.1
2.44.0
- 7.3.2.Final
0.10.2
1.1
0.10.2
@@ -71,13 +70,22 @@
- org.xerial
- sqlite-jdbc
+ org.postgresql
+ postgresql
+ runtime
- org.hibernate.orm
- hibernate-community-dialects
- ${hibernate-dialects.version}
+ com.h2database
+ h2
+ runtime
+
+
+ org.springframework.boot
+ spring-boot-starter-flyway
+
+
+ org.flywaydb
+ flyway-database-postgresql
diff --git a/scripts/migrate-sqlite-to-postgres.sh b/scripts/migrate-sqlite-to-postgres.sh
new file mode 100755
index 0000000000..4f8e7a29b5
--- /dev/null
+++ b/scripts/migrate-sqlite-to-postgres.sh
@@ -0,0 +1,280 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+SQLITE_BIN="${SQLITE_BIN:-/usr/bin/sqlite3}"
+PGLOADER_BIN="${PGLOADER_BIN:-pgloader}"
+PSQL_BIN="${PSQL_BIN:-psql}"
+
+SQLITE_DB="${SQLITE_DB:-}"
+POSTGRES_URL="${POSTGRES_URL:-${DATABASE_URL:-}}"
+
+if [[ -z "$SQLITE_DB" ]]; then
+ echo "SQLITE_DB must point at the source tibot.db file." >&2
+ exit 2
+fi
+
+if [[ -z "$POSTGRES_URL" && "${SPRING_DATASOURCE_URL:-}" == jdbc:postgresql://* ]]; then
+ if [[ -z "${SPRING_DATASOURCE_USERNAME:-}" || -z "${SPRING_DATASOURCE_PASSWORD:-}" ]]; then
+ echo "Set POSTGRES_URL, or set SPRING_DATASOURCE_URL plus SPRING_DATASOURCE_USERNAME/PASSWORD." >&2
+ exit 2
+ fi
+ POSTGRES_URL="postgresql://${SPRING_DATASOURCE_USERNAME}:${SPRING_DATASOURCE_PASSWORD}@${SPRING_DATASOURCE_URL#jdbc:postgresql://}"
+fi
+
+if [[ -z "$POSTGRES_URL" ]]; then
+ echo "POSTGRES_URL must point at the target Postgres database, e.g. postgresql://tibot:tibot@localhost:5432/tibot." >&2
+ exit 2
+fi
+
+if [[ ! -f "$SQLITE_DB" ]]; then
+ echo "SQLite database not found: $SQLITE_DB" >&2
+ exit 2
+fi
+
+if [[ "$SQLITE_DB" == *"'"* ]]; then
+ echo "SQLITE_DB paths containing single quotes are not supported by this migration script." >&2
+ exit 2
+fi
+
+if ! command -v "$SQLITE_BIN" >/dev/null; then
+ echo "sqlite3 not found: $SQLITE_BIN" >&2
+ exit 2
+fi
+
+if ! command -v "$PGLOADER_BIN" >/dev/null; then
+ echo "pgloader not found: $PGLOADER_BIN" >&2
+ exit 2
+fi
+
+if ! command -v "$PSQL_BIN" >/dev/null; then
+ echo "psql not found: $PSQL_BIN" >&2
+ exit 2
+fi
+
+TABLES=(
+ bot_discord_message
+ combat_candidate
+ combat_candidate_event
+ combat_contest
+ combat_contest_side_bets
+ combat_observation
+ combat_replay_hacan_market_compact_decision
+ combat_replay_hacan_subsidy
+ combat_replay_hacan_subsidy_vote
+ combat_replay_hacan_trade_convoys
+ combat_replay_hacan_trade_convoys_vote
+ combat_replay_house
+ combat_replay_house_ability_use
+ combat_replay_house_ability_vote
+ combat_replay_house_opt_out
+ combat_replay_house_score
+ combat_replay_leaderboard_entry
+ combat_replay_prediction
+ discord_user
+ game
+ game_round_player_stats
+ game_round_player_stats_snapshot
+ map_image_data
+ player
+ player_aggregates
+ player_map_image_data
+ standalone_title
+ title
+ tournament_winner
+)
+
+FKS=(
+ "player fk_player_user foreign key (user_id) references discord_user (id)"
+ "player fk_player_game foreign key (game_name) references game (game_name)"
+ "title fk_title_user foreign key (user_id) references discord_user (id)"
+ "title fk_title_game foreign key (game_name) references game (game_name)"
+ "standalone_title fk_standalone_title_user foreign key (user_id) references discord_user (id)"
+)
+
+WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/ti4-pgloader.XXXXXX")"
+SANITIZED_DB="$WORKDIR/tibot.pgloader.sqlite"
+LOAD_FILE="$WORKDIR/sqlite-to-postgres.load"
+SOURCE_COUNTS="$WORKDIR/source-counts.tsv"
+TARGET_COUNTS="$WORKDIR/target-counts.tsv"
+FKS_DROPPED=0
+
+cleanup() {
+ if [[ "${TI4_MIGRATION_KEEP_WORKDIR:-0}" == "1" ]]; then
+ echo "Keeping migration workdir: $WORKDIR"
+ else
+ rm -rf "$WORKDIR"
+ fi
+}
+trap cleanup EXIT
+
+restore_foreign_keys_on_error() {
+ if [[ "$FKS_DROPPED" == "1" ]]; then
+ echo "Migration failed after foreign keys were dropped; attempting to restore them..." >&2
+ add_foreign_keys || true
+ fi
+}
+trap restore_foreign_keys_on_error ERR
+
+quote_csv_tables() {
+ local first=1
+ for table in "${TABLES[@]}"; do
+ if [[ "$first" == 1 ]]; then
+ printf " '%s'" "$table"
+ first=0
+ else
+ printf ",\n '%s'" "$table"
+ fi
+ done
+ printf "\n"
+}
+
+run_psql() {
+ "$PSQL_BIN" "$POSTGRES_URL" -v ON_ERROR_STOP=1 "$@"
+}
+
+drop_foreign_keys() {
+ for fk in "${FKS[@]}"; do
+ read -r table name _ <<<"$fk"
+ run_psql -c "alter table if exists ${table} drop constraint if exists ${name};"
+ done
+}
+
+add_foreign_keys() {
+ for fk in "${FKS[@]}"; do
+ read -r table name definition <<<"$fk"
+ run_psql -c "alter table ${table} add constraint ${name} ${definition};"
+ done
+}
+
+echo "Checking SQLite source integrity..."
+"$SQLITE_BIN" "file:$SQLITE_DB?mode=ro" "pragma integrity_check;"
+
+echo "Checking required target tables..."
+for table in "${TABLES[@]}"; do
+ exists="$(run_psql -At -c "select to_regclass('public.${table}') is not null;")"
+ if [[ "$exists" != "t" ]]; then
+ echo "Missing target table public.${table}. Apply V1__baseline_postgresql_schema.sql first." >&2
+ exit 1
+ fi
+done
+
+echo "Creating sanitized SQLite copy for pgloader..."
+"$SQLITE_BIN" "$SQLITE_DB" "vacuum into '$SANITIZED_DB';"
+
+if "$SQLITE_BIN" "$SANITIZED_DB" "select 1 from pragma_table_info('player') where name = 'username';" | grep -q 1; then
+ "$SQLITE_BIN" "$SANITIZED_DB" "alter table player drop column username;"
+fi
+
+if "$SQLITE_BIN" "$SANITIZED_DB" "select count(*) from sqlite_master where type = 'table' and name = 'standalone_title';" | grep -q 1; then
+ "$SQLITE_BIN" "$SANITIZED_DB" <<'SQL'
+create table standalone_title_dedup (
+ id integer,
+ source varchar(255) not null,
+ title varchar(255) not null,
+ user_id varchar(255) not null
+);
+insert into standalone_title_dedup (id, source, title, user_id)
+select min(id), source, title, user_id
+ from standalone_title
+ group by user_id, title, source;
+drop table standalone_title;
+alter table standalone_title_dedup rename to standalone_title;
+SQL
+fi
+
+for table in "${TABLES[@]}"; do
+ exists="$("$SQLITE_BIN" "$SANITIZED_DB" "select count(*) from sqlite_master where type = 'table' and name = '$table';")"
+ if [[ "$exists" != "1" ]]; then
+ echo "Missing source table ${table} in $SQLITE_DB." >&2
+ exit 1
+ fi
+done
+
+{
+ echo "load database"
+ echo " from sqlite://$SANITIZED_DB"
+ echo " into $POSTGRES_URL"
+ echo
+ echo "with data only, reset sequences"
+ echo
+ echo "cast"
+ cat <<'CASTS'
+ column combat_candidate.started_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_candidate.resolved_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_candidate.promoted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_candidate.pending_resolution_started_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_candidate.mentak_preview_posted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_candidate_event.occurred_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_contest.posted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_contest.side_bet_market_posted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_contest.replay_start_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_contest.next_replay_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_contest.replay_completed_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_contest.pre_replay_context_posted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_contest.leaderboard_posted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_contest_side_bets.placed_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_contest_side_bets.resolved_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_observation.started_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_hacan_market_compact_decision.decided_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_hacan_subsidy.selected_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_hacan_subsidy_vote.voted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_hacan_trade_convoys.selected_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_hacan_trade_convoys_vote.voted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_house.assigned_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_house.updated_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_house_ability_use.used_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_house_ability_vote.voted_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_house_opt_out.assigned_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_house_opt_out.opted_out_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_house_score.scored_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_leaderboard_entry.updated_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_prediction.announced_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_prediction.locked_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column combat_replay_prediction.scored_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp,
+ column player_map_image_data.updated_at to timestamp using ti4-sqlite-millis-or-timestamp-to-timestamp
+CASTS
+ echo
+ echo "including only table names like"
+ quote_csv_tables
+ echo ";"
+} > "$LOAD_FILE"
+
+echo "Recording source row counts..."
+for table in "${TABLES[@]}"; do
+ count="$("$SQLITE_BIN" "$SANITIZED_DB" "select count(*) from \"$table\";")"
+ printf "%s\t%s\n" "$table" "$count" >> "$SOURCE_COUNTS"
+done
+
+if [[ "${TI4_MIGRATION_TRUNCATE_TARGET:-1}" == "1" ]]; then
+ echo "Truncating target tables..."
+ table_csv="$(printf ", public.%s" "${TABLES[@]}")"
+ run_psql -c "truncate table ${table_csv#, } restart identity cascade;"
+fi
+
+echo "Dropping app foreign keys during bulk load..."
+drop_foreign_keys
+FKS_DROPPED=1
+
+echo "Running pgloader..."
+"$PGLOADER_BIN" -l "$ROOT_DIR/scripts/pgloader-ti4-transforms.lisp" "$LOAD_FILE"
+
+echo "Re-adding app foreign keys..."
+add_foreign_keys
+FKS_DROPPED=0
+
+echo "Recording target row counts..."
+for table in "${TABLES[@]}"; do
+ count="$(run_psql -At -c "select count(*) from public.${table};")"
+ printf "%s\t%s\n" "$table" "$count" >> "$TARGET_COUNTS"
+done
+
+if ! diff -u "$SOURCE_COUNTS" "$TARGET_COUNTS"; then
+ echo "Row count mismatch after migration." >&2
+ echo "Source counts: $SOURCE_COUNTS" >&2
+ echo "Target counts: $TARGET_COUNTS" >&2
+ exit 1
+fi
+
+echo "Migration complete. Row counts match for ${#TABLES[@]} tables."
diff --git a/scripts/pgloader-ti4-transforms.lisp b/scripts/pgloader-ti4-transforms.lisp
new file mode 100644
index 0000000000..7e1a05a48e
--- /dev/null
+++ b/scripts/pgloader-ti4-transforms.lisp
@@ -0,0 +1,19 @@
+(in-package #:pgloader.transforms)
+
+(defun ti4-sqlite-millis-or-timestamp-to-timestamp (value)
+ (cond
+ ((null value) nil)
+ ((and (stringp value) (= 0 (length value))) nil)
+ ((integerp value) (ti4-millis-to-timestamp value))
+ ((and (stringp value) (every #'digit-char-p value))
+ (ti4-millis-to-timestamp (parse-integer value)))
+ (t value)))
+
+(defun ti4-millis-to-timestamp (millis)
+ (let* ((seconds (floor millis 1000))
+ (milliseconds (mod millis 1000))
+ (unix-epoch-in-universal-time 2208988800))
+ (multiple-value-bind (second minute hour day month year)
+ (decode-universal-time (+ unix-epoch-in-universal-time seconds) 0)
+ (format nil "~4,'0d-~2,'0d-~2,'0d ~2,'0d:~2,'0d:~2,'0d.~3,'0d"
+ year month day hour minute second milliseconds))))
diff --git a/src/main/java/ti4/contest/replay/entities/CombatCandidateEventEntity.java b/src/main/java/ti4/contest/replay/entities/CombatCandidateEventEntity.java
index bd53584f5d..a538c34dca 100644
--- a/src/main/java/ti4/contest/replay/entities/CombatCandidateEventEntity.java
+++ b/src/main/java/ti4/contest/replay/entities/CombatCandidateEventEntity.java
@@ -22,7 +22,11 @@
name = "idx_combat_candidate_event_candidate_sequence",
columnList = "candidate_id, sequence_number",
unique = true),
- @Index(name = "idx_combat_candidate_event_candidate_occurred_at", columnList = "candidate_id, occurred_at")
+ @Index(name = "idx_combat_candidate_event_candidate_occurred_at", columnList = "candidate_id, occurred_at"),
+ @Index(
+ name = "idx_combat_candidate_event_candidate_type",
+ columnList = "candidate_id, event_type, actor_faction, round_number"),
+ @Index(name = "idx_combat_candidate_event_occurred_at", columnList = "occurred_at")
})
/**
* Persists the ordered replay event stream for a single combat candidate.
diff --git a/src/main/java/ti4/contest/replay/entities/CombatReplayContestEntity.java b/src/main/java/ti4/contest/replay/entities/CombatReplayContestEntity.java
index 9b0f9758c3..47623ce5f7 100644
--- a/src/main/java/ti4/contest/replay/entities/CombatReplayContestEntity.java
+++ b/src/main/java/ti4/contest/replay/entities/CombatReplayContestEntity.java
@@ -19,7 +19,15 @@
name = "combat_contest",
indexes = {
@Index(name = "idx_replay_contest_replay_status_due", columnList = "replay_status, next_replay_at"),
- @Index(name = "idx_replay_contest_posted_at", columnList = "posted_at")
+ @Index(name = "idx_replay_contest_posted_at", columnList = "posted_at"),
+ @Index(name = "idx_replay_contest_public_message", columnList = "public_message_id"),
+ @Index(name = "idx_replay_contest_public_thread", columnList = "public_thread_id"),
+ @Index(
+ name = "idx_replay_contest_side_bet_posting",
+ columnList = "replay_status, side_bet_market_posted_at, posted_at"),
+ @Index(
+ name = "idx_replay_contest_leaderboard_posting",
+ columnList = "replay_status, leaderboard_posted_at, replay_completed_at")
})
/**
* Tracks the public replay contest lifecycle once a candidate has been promoted.
diff --git a/src/main/java/ti4/contest/replay/entities/CombatReplayLeaderboardEntryEntity.java b/src/main/java/ti4/contest/replay/entities/CombatReplayLeaderboardEntryEntity.java
index 9bd8c1f968..65f42fd323 100644
--- a/src/main/java/ti4/contest/replay/entities/CombatReplayLeaderboardEntryEntity.java
+++ b/src/main/java/ti4/contest/replay/entities/CombatReplayLeaderboardEntryEntity.java
@@ -17,7 +17,12 @@
@Entity
@Table(
name = "combat_replay_leaderboard_entry",
- indexes = @Index(name = "idx_replay_leaderboard_points", columnList = "total_points"),
+ indexes = {
+ @Index(name = "idx_replay_leaderboard_points", columnList = "total_points"),
+ @Index(
+ name = "idx_replay_leaderboard_rank",
+ columnList = "total_points, correct_predictions, prediction_count, discord_user_name")
+ },
uniqueConstraints = @UniqueConstraint(name = "uk_replay_leaderboard_user", columnNames = "discord_user_id"))
/**
* Stores cumulative replay leaderboard totals per Discord user.
diff --git a/src/main/java/ti4/service/persistence/DatabasePersistenceGate.java b/src/main/java/ti4/service/persistence/DatabasePersistenceGate.java
index 21a8cafb2a..294de58392 100644
--- a/src/main/java/ti4/service/persistence/DatabasePersistenceGate.java
+++ b/src/main/java/ti4/service/persistence/DatabasePersistenceGate.java
@@ -1,12 +1,16 @@
package ti4.service.persistence;
import lombok.experimental.UtilityClass;
+import org.apache.commons.lang3.StringUtils;
import ti4.settings.GlobalSettings;
@UtilityClass
public class DatabasePersistenceGate {
public static boolean isDisabled() {
+ if (isLocalFallbackDatabase()) {
+ return true;
+ }
return GlobalSettings.ImplementedSettings.SQLITE_PERSISTENCE_DISABLED.getAsBoolean(false);
}
@@ -18,7 +22,14 @@ public static String statusMessage() {
if (!isDisabled()) {
return "Database maintenance mode is **OFF**. Database-backed features are enabled.";
}
- return "Database maintenance mode is **ON**. Game commands and file saves still run, but statistics, dashboard data,"
+ String source = isLocalFallbackDatabase() ? " because no external datasource is configured" : "";
+ return "Database maintenance mode is **ON**"
+ + source
+ + ". Game commands and file saves still run, but statistics, dashboard data,"
+ " map image metadata, message cache, and SQL developer tools are temporarily paused.";
}
+
+ private static boolean isLocalFallbackDatabase() {
+ return StringUtils.isBlank(System.getenv("SPRING_DATASOURCE_URL"));
+ }
}
diff --git a/src/main/java/ti4/spring/api/dashboard/PlayerAggregatesCache.java b/src/main/java/ti4/spring/api/dashboard/PlayerAggregatesCache.java
index 0c996ebc61..518be59d3a 100644
--- a/src/main/java/ti4/spring/api/dashboard/PlayerAggregatesCache.java
+++ b/src/main/java/ti4/spring/api/dashboard/PlayerAggregatesCache.java
@@ -3,7 +3,6 @@
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
-import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -37,8 +36,7 @@ class PlayerAggregatesCache {
@Column(name = "aggregates_version", nullable = false)
private int aggregatesVersion;
- @Lob
- @Column(name = "aggregates_json", nullable = false)
+ @Column(name = "aggregates_json", nullable = false, columnDefinition = "TEXT")
private String aggregatesJson;
@Column(name = "computed_at_epoch_ms", nullable = false)
diff --git a/src/main/java/ti4/spring/service/deploy/ActiveLeaseTransactionService.java b/src/main/java/ti4/spring/service/deploy/ActiveLeaseTransactionService.java
index 221a505c98..b75f0ed8b5 100644
--- a/src/main/java/ti4/spring/service/deploy/ActiveLeaseTransactionService.java
+++ b/src/main/java/ti4/spring/service/deploy/ActiveLeaseTransactionService.java
@@ -2,6 +2,7 @@
import java.time.Instant;
import lombok.RequiredArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -16,12 +17,29 @@ public class ActiveLeaseTransactionService {
public boolean tryAcquireLease(String instanceId) {
Instant now = Instant.now();
String leaseKey = leaseProperties.getLeaseKey();
- activeLeaseRepository.insertLeaseRowIfAbsent(leaseKey, "", Instant.EPOCH, Instant.EPOCH);
+ insertLeaseRowIfAbsent(leaseKey);
return activeLeaseRepository.claimLease(
leaseKey, instanceId, now.plusSeconds(leaseProperties.getLeaseDurationSeconds()), now, now)
== 1;
}
+ private void insertLeaseRowIfAbsent(String leaseKey) {
+ if (StringUtils.isBlank(System.getenv("SPRING_DATASOURCE_URL"))) {
+ if (activeLeaseRepository.existsById(leaseKey)) {
+ return;
+ }
+ var lease = new ActiveLeaseEntity();
+ lease.setLeaseKey(leaseKey);
+ lease.setInstanceId("");
+ lease.setLeaseExpiresAt(Instant.EPOCH);
+ lease.setHeartbeatAt(Instant.EPOCH);
+ activeLeaseRepository.save(lease);
+ return;
+ }
+
+ activeLeaseRepository.insertLeaseRowIfAbsent(leaseKey, "", Instant.EPOCH, Instant.EPOCH);
+ }
+
@Transactional
public boolean renewLease(String instanceId) {
Instant now = Instant.now();
diff --git a/src/main/java/ti4/spring/service/persistence/GameEntity.java b/src/main/java/ti4/spring/service/persistence/GameEntity.java
index cf900f54e2..68280c82a3 100644
--- a/src/main/java/ti4/spring/service/persistence/GameEntity.java
+++ b/src/main/java/ti4/spring/service/persistence/GameEntity.java
@@ -5,6 +5,7 @@
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
+import jakarta.persistence.Index;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.ArrayList;
@@ -18,7 +19,17 @@
@Setter
@NoArgsConstructor
@Entity
-@Table(name = "game")
+@Table(
+ name = "game",
+ indexes = {
+ @Index(name = "idx_game_ended_at", columnList = "ended_epoch_milliseconds"),
+ @Index(
+ name = "idx_game_completed_six_player_non_alliance",
+ columnList = "is_completed, is_alliance_mode, player_count, is_twilight_imperium_global_league"),
+ @Index(
+ name = "idx_game_active_tigl",
+ columnList = "is_twilight_imperium_global_league, ended_epoch_milliseconds")
+ })
public class GameEntity {
@Id
diff --git a/src/main/java/ti4/spring/service/persistence/PlayerEntity.java b/src/main/java/ti4/spring/service/persistence/PlayerEntity.java
index 719895ce12..d6d4f6df72 100644
--- a/src/main/java/ti4/spring/service/persistence/PlayerEntity.java
+++ b/src/main/java/ti4/spring/service/persistence/PlayerEntity.java
@@ -6,6 +6,7 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
+import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@@ -19,7 +20,10 @@
@Setter
@NoArgsConstructor
@Entity
-@Table(name = "player", uniqueConstraints = @UniqueConstraint(columnNames = {"game_name", "user_id"}))
+@Table(
+ name = "player",
+ indexes = @Index(name = "idx_player_user", columnList = "user_id"),
+ uniqueConstraints = @UniqueConstraint(columnNames = {"game_name", "user_id"}))
public class PlayerEntity {
@Id
diff --git a/src/main/java/ti4/spring/service/persistence/TitleEntity.java b/src/main/java/ti4/spring/service/persistence/TitleEntity.java
index 2b5ef2d956..d45fa5045f 100644
--- a/src/main/java/ti4/spring/service/persistence/TitleEntity.java
+++ b/src/main/java/ti4/spring/service/persistence/TitleEntity.java
@@ -6,6 +6,7 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
+import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@@ -17,7 +18,12 @@
@Setter
@NoArgsConstructor
@Entity
-@Table(name = "title")
+@Table(
+ name = "title",
+ indexes = {
+ @Index(name = "idx_title_user", columnList = "user_id"),
+ @Index(name = "idx_title_game", columnList = "game_name")
+ })
public class TitleEntity {
@Id
diff --git a/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStats.java b/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStats.java
index 23522c1f06..b65ab26d80 100644
--- a/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStats.java
+++ b/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStats.java
@@ -4,6 +4,7 @@
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
+import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -12,7 +13,9 @@
@NoArgsConstructor
@Entity
@IdClass(GameRoundPlayerStatsId.class)
-@Table(name = "game_round_player_stats")
+@Table(
+ name = "game_round_player_stats",
+ indexes = @Index(name = "idx_game_round_player_stats_user_game", columnList = "user_discord_id, game_id"))
public class GameRoundPlayerStats {
@Id
diff --git a/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStatsSnapshot.java b/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStatsSnapshot.java
index 7f1350e9a8..1c207224d9 100644
--- a/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStatsSnapshot.java
+++ b/src/main/java/ti4/spring/service/roundstats/GameRoundPlayerStatsSnapshot.java
@@ -4,6 +4,7 @@
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
+import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -12,7 +13,9 @@
@NoArgsConstructor
@Entity
@IdClass(GameRoundPlayerStatsSnapshotId.class)
-@Table(name = "game_round_player_stats_snapshot")
+@Table(
+ name = "game_round_player_stats_snapshot",
+ indexes = @Index(name = "idx_game_round_player_stats_snapshot_game_undo", columnList = "game_id, undo_index"))
public class GameRoundPlayerStatsSnapshot {
@Id
diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml
index b4860b5398..4e1a915c24 100644
--- a/src/main/resources/config/application.yml
+++ b/src/main/resources/config/application.yml
@@ -9,18 +9,18 @@ management:
spring:
datasource:
- url: "jdbc:sqlite:${DB_PATH:./}/tibot.db"
- driver-class-name: org.sqlite.JDBC
+ url: ${SPRING_DATASOURCE_URL:jdbc:h2:mem:tibot;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH}
+ username: ${SPRING_DATASOURCE_USERNAME:sa}
+ password: ${SPRING_DATASOURCE_PASSWORD:}
hikari:
- # SQLite permits only one writer at a time; extra pooled connections increase lock contention.
- maximum-pool-size: 1
- connection-init-sql: |
- PRAGMA journal_mode=WAL;
- PRAGMA busy_timeout=30000;
- PRAGMA synchronous=NORMAL;
+ maximum-pool-size: ${SPRING_DATASOURCE_MAXIMUM_POOL_SIZE:10}
+ flyway:
+ enabled: ${SPRING_FLYWAY_ENABLED:false}
+ locations: classpath:db/migration
+ baseline-on-migrate: true
jpa:
hibernate:
- ddl-auto: update
+ ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO:update}
properties:
hibernate:
format_sql: false
diff --git a/src/main/resources/db/migration/V1__baseline_postgresql_schema.sql b/src/main/resources/db/migration/V1__baseline_postgresql_schema.sql
new file mode 100644
index 0000000000..82bfdb2c18
--- /dev/null
+++ b/src/main/resources/db/migration/V1__baseline_postgresql_schema.sql
@@ -0,0 +1,441 @@
+create table active_lease (
+ lease_key text not null primary key,
+ heartbeat_at timestamp(6) with time zone,
+ instance_id text,
+ lease_expires_at timestamp(6) with time zone
+);
+
+create table bot_discord_message (
+ message_id bigint not null primary key,
+ content text,
+ created_at_epoch_millis bigint not null
+);
+
+create table combat_observation (
+ id bigint generated by default as identity primary key,
+ started_at timestamp(6) not null,
+ game_name text not null,
+ tile_position text not null,
+ attacker_faction text not null,
+ defender_faction text not null,
+ attacker_strength double precision not null,
+ defender_strength double precision not null,
+ attacker_hp double precision not null,
+ defender_hp double precision not null,
+ attacker_expected_hits double precision not null,
+ defender_expected_hits double precision not null,
+ fairness_ratio double precision not null,
+ joint_score double precision not null
+);
+
+create table combat_candidate (
+ id bigint generated by default as identity primary key,
+ observation_id bigint not null unique,
+ status text not null,
+ promotion_status text not null,
+ next_event_sequence integer not null,
+ started_at timestamp(6) not null,
+ resolved_at timestamp(6),
+ pending_resolution_started_at timestamp(6),
+ promoted_at timestamp(6),
+ mentak_preview_posted_at timestamp(6),
+ game_name text not null,
+ tile_position text not null,
+ attacker_faction text not null,
+ defender_faction text not null,
+ winner_faction text,
+ loser_faction text,
+ resolution_reason text,
+ cancellation_reason text,
+ pre_replay_context_text text,
+ initial_render_snapshot_json text,
+ replay_abilities_json text,
+ promotion_score double precision,
+ side_bet_compatible boolean not null default false,
+ attacker_destroyer_count integer not null default 0,
+ defender_destroyer_count integer not null default 0,
+ attacker_has_assault_cannon boolean not null default false,
+ defender_has_assault_cannon boolean not null default false,
+ attacker_hp double precision,
+ defender_hp double precision,
+ attacker_strength double precision,
+ defender_strength double precision,
+ attacker_rolled_afb boolean not null default false,
+ defender_rolled_afb boolean not null default false,
+ attacker_afb_whiff boolean not null default false,
+ defender_afb_whiff boolean not null default false,
+ attacker_skipped_afb boolean not null default false,
+ defender_skipped_afb boolean not null default false,
+ attacker_round_one_whiff boolean not null default false,
+ defender_round_one_whiff boolean not null default false,
+ attacker_round_one_slam boolean not null default false,
+ defender_round_one_slam boolean not null default false,
+ attacker_played_morale_boost boolean not null default false,
+ defender_played_morale_boost boolean not null default false,
+ attacker_played_shields_holding boolean not null default false,
+ defender_played_shields_holding boolean not null default false,
+ attacker_played_direct_hit boolean not null default false,
+ defender_played_direct_hit boolean not null default false,
+ attacker_played_fighter_prototype boolean not null default false,
+ defender_played_fighter_prototype boolean not null default false,
+ winner_one_hp_remaining boolean not null default false,
+ winner_remaining_hp double precision
+);
+
+create table combat_candidate_event (
+ id bigint generated by default as identity primary key,
+ candidate_id bigint not null,
+ occurred_at timestamp(6) not null,
+ sequence_number integer not null,
+ event_type text not null,
+ round_number integer,
+ actor_faction text,
+ summary_text text not null,
+ payload_json text
+);
+
+create table combat_contest (
+ id bigint generated by default as identity primary key,
+ candidate_id bigint not null unique,
+ posted_at timestamp(6),
+ public_channel_id bigint,
+ public_message_id bigint,
+ public_thread_id bigint,
+ side_bet_summary_message_id bigint,
+ side_bet_attacker_button_message_id bigint,
+ side_bet_defender_button_message_id bigint,
+ side_bet_market_posted_at timestamp(6),
+ side_bet_payout_model text,
+ replay_status text not null,
+ replay_start_at timestamp(6),
+ next_replay_at timestamp(6),
+ next_event_sequence integer not null,
+ replay_completed_at timestamp(6),
+ pre_replay_context_posted_at timestamp(6),
+ leaderboard_posted_at timestamp(6),
+ replay_error text
+);
+
+create table combat_contest_side_bets (
+ id bigint generated by default as identity primary key,
+ contest_id bigint not null,
+ discord_user_id text not null,
+ discord_user_name text not null,
+ bet_type text not null,
+ target_faction text not null,
+ offered_profit_points integer,
+ placed_at timestamp(6) not null,
+ resolved_at timestamp(6)
+);
+
+create table combat_replay_hacan_market_compact_decision (
+ id bigint generated by default as identity primary key,
+ contest_id bigint not null,
+ decided_at timestamp(6) not null,
+ constraint uk_hacan_market_compact_decision_contest unique (contest_id)
+);
+
+create table combat_replay_hacan_subsidy (
+ id bigint generated by default as identity primary key,
+ contest_id bigint not null,
+ bet_type text not null,
+ target_faction text not null,
+ vote_count integer not null,
+ selected_at timestamp(6) not null,
+ constraint uk_hacan_subsidy_contest_bet unique (contest_id, bet_type, target_faction)
+);
+
+create table combat_replay_hacan_subsidy_vote (
+ id bigint generated by default as identity primary key,
+ contest_id bigint not null,
+ discord_user_id text not null,
+ discord_user_name text not null,
+ bet_type text not null,
+ target_faction text not null,
+ voted_at timestamp(6) not null,
+ constraint uk_hacan_subsidy_vote_user_bet unique (contest_id, discord_user_id, bet_type, target_faction)
+);
+
+create table combat_replay_hacan_trade_convoys (
+ id bigint generated by default as identity primary key,
+ contest_id bigint not null,
+ target_house text,
+ favor_cost integer not null,
+ prediction_bonus integer not null,
+ vote_count integer not null,
+ selected_at timestamp(6) not null,
+ constraint uk_hacan_trade_convoys_contest unique (contest_id)
+);
+
+create table combat_replay_hacan_trade_convoys_vote (
+ id bigint generated by default as identity primary key,
+ contest_id bigint not null,
+ target_house text,
+ favor_cost integer not null,
+ prediction_bonus integer not null,
+ discord_user_id text not null,
+ discord_user_name text not null,
+ voted_at timestamp(6) not null,
+ constraint uk_hacan_trade_convoys_vote_contest_user unique (contest_id, discord_user_id)
+);
+
+create table combat_replay_house (
+ id bigint generated by default as identity primary key,
+ discord_user_id text not null,
+ discord_user_name text not null,
+ house text not null,
+ assigned_at timestamp(6) not null,
+ updated_at timestamp(6) not null,
+ constraint uk_replay_house_user unique (discord_user_id)
+);
+
+create table combat_replay_house_ability_use (
+ id bigint generated by default as identity primary key,
+ candidate_id bigint not null,
+ house text not null,
+ favor_cost integer not null,
+ discord_user_id text not null,
+ discord_user_name text not null,
+ used_at timestamp(6) not null,
+ constraint uk_replay_house_ability_use_candidate_house unique (candidate_id, house)
+);
+
+create table combat_replay_house_ability_vote (
+ id bigint generated by default as identity primary key,
+ candidate_id bigint not null,
+ house text not null,
+ option_key text not null,
+ discord_user_id text not null,
+ discord_user_name text not null,
+ voted_at timestamp(6) not null,
+ constraint uk_house_ability_vote_candidate_house_user unique (candidate_id, house, discord_user_id)
+);
+
+create table combat_replay_house_opt_out (
+ id bigint generated by default as identity primary key,
+ discord_user_id text not null,
+ discord_user_name text not null,
+ house text not null,
+ assigned_at timestamp(6) not null,
+ opted_out_at timestamp(6) not null,
+ constraint uk_replay_house_opt_out_user unique (discord_user_id)
+);
+
+create table combat_replay_house_score (
+ id bigint generated by default as identity primary key,
+ contest_id bigint not null,
+ house text not null,
+ prediction_points integer not null,
+ side_bet_points integer not null,
+ ability_points integer not null,
+ favor_points integer,
+ total_points integer not null,
+ prediction_count integer not null,
+ correct_predictions integer not null,
+ ability_breakdown_json text not null,
+ scored_at timestamp(6) not null,
+ constraint uk_replay_house_score_contest_house unique (contest_id, house)
+);
+
+create table combat_replay_leaderboard_entry (
+ id bigint generated by default as identity primary key,
+ discord_user_id text not null,
+ discord_user_name text not null,
+ total_points integer not null,
+ prediction_count integer not null,
+ correct_predictions integer not null,
+ updated_at timestamp(6) not null,
+ constraint uk_replay_leaderboard_user unique (discord_user_id)
+);
+
+create table combat_replay_prediction (
+ id bigint generated by default as identity primary key,
+ contest_id bigint not null,
+ locked_at timestamp(6) not null,
+ announced_at timestamp(6),
+ scored_at timestamp(6),
+ attacker_prediction_count integer not null,
+ defender_prediction_count integer not null,
+ mentak_prediction_override_count integer not null default 0,
+ attacker_predictions_json text not null,
+ defender_predictions_json text not null,
+ constraint uk_replay_prediction_contest unique (contest_id)
+);
+
+create table discord_user (
+ id text not null primary key,
+ name text not null
+);
+
+create table game (
+ game_name text not null primary key,
+ creation_epoch_milliseconds bigint not null,
+ ended_epoch_milliseconds bigint,
+ round_number integer not null,
+ victory_point_goal integer not null,
+ is_completed boolean not null,
+ is_prophecy_of_kings boolean not null,
+ is_thunders_edge boolean not null,
+ is_fracture_in_play boolean not null,
+ is_homebrew boolean not null,
+ is_discordant_stars_mode boolean not null,
+ is_absol_mode boolean not null,
+ is_franken_mode boolean not null,
+ is_alliance_mode boolean not null,
+ is_twilight_imperium_global_league boolean not null,
+ is_twilight_imperium_global_league_fractured boolean not null,
+ twilight_imperium_global_league_rank text,
+ player_count integer not null
+);
+
+create table game_round_player_stats (
+ game_id text not null,
+ user_discord_id text not null,
+ round integer not null,
+ max_army_cost double precision,
+ last_seen_army_cost double precision,
+ sc_picks text,
+ combats_initiated integer,
+ tacticals_with_combat integer,
+ planets_taken integer,
+ planets_stolen integer,
+ techs_gained text,
+ dice_rolled integer,
+ turn_times text,
+ primary key (game_id, round, user_discord_id)
+);
+
+create table game_round_player_stats_snapshot (
+ game_id text not null,
+ undo_index integer not null,
+ user_discord_id text not null,
+ round integer not null,
+ max_army_cost double precision,
+ last_seen_army_cost double precision,
+ sc_picks text,
+ combats_initiated integer,
+ tacticals_with_combat integer,
+ planets_taken integer,
+ planets_stolen integer,
+ techs_gained text,
+ dice_rolled integer,
+ turn_times text,
+ primary key (game_id, round, undo_index, user_discord_id)
+);
+
+create table map_image_data (
+ game_name text not null primary key,
+ latest_map_image_name text,
+ latest_discord_message_id bigint,
+ latest_discord_guild_id bigint,
+ latest_discord_channel_id bigint
+);
+
+create table player (
+ id bigint generated by default as identity primary key,
+ user_id text not null,
+ game_name text not null,
+ faction_name text,
+ score integer not null,
+ total_number_of_turns integer not null,
+ total_turn_time bigint not null,
+ expected_hits double precision not null,
+ actual_hits integer not null,
+ is_eliminated boolean not null,
+ is_winner boolean not null,
+ is_replaced boolean not null,
+ constraint uk_player_game_user unique (game_name, user_id)
+);
+
+create table player_aggregates (
+ user_id text not null primary key,
+ completed_games_hash text not null,
+ completed_game_count integer not null,
+ aggregates_version integer not null,
+ aggregates_json text not null,
+ computed_at_epoch_ms bigint not null,
+ last_error text,
+ last_error_at_epoch_ms bigint
+);
+
+create table player_map_image_data (
+ id bigint generated by default as identity primary key,
+ game_name text not null,
+ player_id text not null,
+ discord_message_id bigint,
+ discord_channel_id bigint,
+ updated_at timestamp(6),
+ constraint uk_player_map_image_data_game_player unique (game_name, player_id)
+);
+
+create table title (
+ id bigint generated by default as identity primary key,
+ game_name text not null,
+ user_id text not null,
+ title text not null
+);
+
+create table standalone_title (
+ id bigint generated by default as identity primary key,
+ user_id text not null,
+ title text not null,
+ source text not null,
+ constraint uk_standalone_title_user_title_source unique (user_id, title, source)
+);
+
+create table tournament_winner (
+ id bigint generated by default as identity primary key,
+ user_id text,
+ user_name text,
+ tourney_name text
+);
+
+create index idx_combat_observation_started_at on combat_observation (started_at);
+create index idx_combat_observation_game_tile_started_at on combat_observation (game_name, tile_position, started_at);
+create index idx_combat_candidate_status_started_at on combat_candidate (status, started_at);
+create index idx_combat_candidate_promotion_resolved_at on combat_candidate (promotion_status, resolved_at);
+create index idx_combat_candidate_game_tile_status on combat_candidate (game_name, tile_position, status);
+create index idx_combat_candidate_status_pending_resolution on combat_candidate (status, pending_resolution_started_at);
+create index idx_combat_candidate_promoted_at on combat_candidate (promoted_at);
+create unique index idx_combat_candidate_event_candidate_sequence on combat_candidate_event (candidate_id, sequence_number);
+create index idx_combat_candidate_event_candidate_occurred_at on combat_candidate_event (candidate_id, occurred_at);
+create index idx_combat_candidate_event_candidate_type on combat_candidate_event (candidate_id, event_type, actor_faction, round_number);
+create index idx_combat_candidate_event_occurred_at on combat_candidate_event (occurred_at);
+create index idx_replay_contest_replay_status_due on combat_contest (replay_status, next_replay_at);
+create index idx_replay_contest_posted_at on combat_contest (posted_at);
+create index idx_replay_contest_public_message on combat_contest (public_message_id);
+create index idx_replay_contest_public_thread on combat_contest (public_thread_id);
+create index idx_replay_contest_side_bet_posting on combat_contest (replay_status, side_bet_market_posted_at, posted_at);
+create index idx_replay_contest_leaderboard_posting on combat_contest (replay_status, leaderboard_posted_at, replay_completed_at);
+create index idx_side_bet_contest_user on combat_contest_side_bets (contest_id, discord_user_id);
+create index idx_side_bet_resolved on combat_contest_side_bets (resolved_at);
+create index idx_hacan_market_compact_decision_contest on combat_replay_hacan_market_compact_decision (contest_id);
+create index idx_hacan_subsidy_contest on combat_replay_hacan_subsidy (contest_id);
+create index idx_hacan_subsidy_vote_contest on combat_replay_hacan_subsidy_vote (contest_id);
+create index idx_hacan_subsidy_vote_user on combat_replay_hacan_subsidy_vote (contest_id, discord_user_id);
+create index idx_hacan_trade_convoys_contest on combat_replay_hacan_trade_convoys (contest_id);
+create index idx_hacan_trade_convoys_vote_contest on combat_replay_hacan_trade_convoys_vote (contest_id);
+create index idx_hacan_trade_convoys_vote_user on combat_replay_hacan_trade_convoys_vote (contest_id, discord_user_id);
+create index idx_replay_house_house on combat_replay_house (house);
+create index idx_replay_house_ability_use_house on combat_replay_house_ability_use (house);
+create index idx_house_ability_vote_candidate_house on combat_replay_house_ability_vote (candidate_id, house);
+create index idx_replay_house_opt_out_house on combat_replay_house_opt_out (house);
+create index idx_replay_house_score_house on combat_replay_house_score (house);
+create index idx_replay_leaderboard_points on combat_replay_leaderboard_entry (total_points);
+create index idx_replay_leaderboard_rank on combat_replay_leaderboard_entry (total_points, correct_predictions, prediction_count, discord_user_name);
+create index idx_replay_prediction_contest_id on combat_replay_prediction (contest_id);
+create index idx_standalone_title_user on standalone_title (user_id);
+create index idx_game_ended_at on game (ended_epoch_milliseconds);
+create index idx_game_completed_six_player_non_alliance on game (is_completed, is_alliance_mode, player_count, is_twilight_imperium_global_league);
+create index idx_game_active_tigl on game (is_twilight_imperium_global_league, ended_epoch_milliseconds);
+create index idx_player_user on player (user_id);
+create index idx_title_user on title (user_id);
+create index idx_title_game on title (game_name);
+create index idx_game_round_player_stats_user_game on game_round_player_stats (user_discord_id, game_id);
+create index idx_game_round_player_stats_snapshot_game_undo on game_round_player_stats_snapshot (game_id, undo_index);
+
+alter table player add constraint fk_player_user foreign key (user_id) references discord_user (id);
+alter table player add constraint fk_player_game foreign key (game_name) references game (game_name);
+alter table title add constraint fk_title_user foreign key (user_id) references discord_user (id);
+alter table title add constraint fk_title_game foreign key (game_name) references game (game_name);
+alter table standalone_title add constraint fk_standalone_title_user foreign key (user_id) references discord_user (id);
diff --git a/src/test/java/ti4/spring/api/image/GameImageServiceTest.java b/src/test/java/ti4/spring/api/image/GameImageServiceTest.java
index dcc96f2998..2eb8f57d3d 100644
--- a/src/test/java/ti4/spring/api/image/GameImageServiceTest.java
+++ b/src/test/java/ti4/spring/api/image/GameImageServiceTest.java
@@ -14,6 +14,7 @@
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import ti4.game.persistence.GameManager;
+import ti4.service.persistence.DatabasePersistenceGate;
class GameImageServiceTest {
@@ -34,7 +35,9 @@ void saveDiscordMessagePersistsDiscordIds() {
when(mapImageDataRepository.findById("pbd11223")).thenReturn(Optional.empty());
when(mapImageDataRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
- try (MockedStatic gameManager = mockStatic(GameManager.class)) {
+ try (MockedStatic persistenceGate = mockStatic(DatabasePersistenceGate.class);
+ MockedStatic gameManager = mockStatic(GameManager.class)) {
+ persistenceGate.when(DatabasePersistenceGate::isDisabled).thenReturn(false);
gameManager.when(() -> GameManager.isValid("pbd11223")).thenReturn(true);
service.saveDiscordMessage("pbd11223", message);