MINESWEEPER 系统设计
原型文档 · 非正式发布返回游戏 ↗
参考源码设计 v0.8.1

multiplayer-schema.sql

参考源码 · docs/account-system/multiplayer-schema.sql

sql
-- 多人领域参考 DDL v0.8.1 / 2026-09-13。
-- 先执行 schema.sql,再执行本文件;仍是一个 SQLite 数据库,不是第二个库。
PRAGMA foreign_keys = ON;
BEGIN IMMEDIATE;

CREATE TABLE online_games (
  id TEXT PRIMARY KEY,
  kind TEXT NOT NULL CHECK (kind IN ('daily_normal','daily_ranked')),
  status TEXT NOT NULL CHECK (status IN ('waiting','countdown','running','resolving','ended','cancelled','invalid')),
  difficulty TEXT NOT NULL CHECK (difficulty IN ('easy','medium','expert')),
  rules_revision TEXT NOT NULL REFERENCES rule_revisions(revision),
  rules_snapshot_json TEXT NOT NULL CHECK (json_valid(rules_snapshot_json)),
  private_map_json TEXT CHECK (private_map_json IS NULL OR json_valid(private_map_json)),
  map_hash TEXT CHECK (map_hash IS NULL OR length(map_hash)=64),
  version INTEGER NOT NULL DEFAULT 1 CHECK (version>0),
  current_result_revision INTEGER NOT NULL DEFAULT 0 CHECK (current_result_revision>=0),
  created_at INTEGER NOT NULL,
  started_at INTEGER,
  total_deadline INTEGER,
  ended_at INTEGER,
  UNIQUE (id,kind),
  CHECK (total_deadline IS NULL OR (started_at IS NOT NULL AND total_deadline>started_at)),
  CHECK (ended_at IS NULL OR ended_at>=created_at)
) STRICT;
CREATE INDEX online_game_deadlines ON online_games(status,total_deadline);

CREATE TABLE online_seats (
  game_id TEXT NOT NULL REFERENCES online_games(id),
  user_id TEXT NOT NULL REFERENCES users(id),
  seat_no INTEGER NOT NULL CHECK (seat_no = 1),
  control_device_id TEXT, -- NULL仅用于终局账号删除后的脱敏引用壳;活跃局由事务服务强制非空
  control_epoch INTEGER NOT NULL DEFAULT 1 CHECK (control_epoch>0),
  input_epoch INTEGER NOT NULL DEFAULT 1 CHECK (input_epoch>0),
  connection_state TEXT NOT NULL CHECK (connection_state IN ('connected','disconnected','restoring')),
  eligibility TEXT NOT NULL CHECK (eligibility IN ('active','finished','forfeited','disqualified')),
  initial_health INTEGER CHECK (initial_health BETWEEN 1 AND 3),
  remaining_health INTEGER CHECK (remaining_health>=0 AND remaining_health<=initial_health),
  damage_count INTEGER NOT NULL DEFAULT 0 CHECK (damage_count>=0),
  injuries INTEGER NOT NULL DEFAULT 0 CHECK (injuries>=0),
  opened_safe INTEGER NOT NULL DEFAULT 0 CHECK (opened_safe>=0),
  remaining_safe_at_start INTEGER NOT NULL CHECK (remaining_safe_at_start>0),
  disconnected_at INTEGER,
  disconnected_total_ms INTEGER NOT NULL DEFAULT 0 CHECK (disconnected_total_ms>=0),
  disconnect_deadline INTEGER,
  idle_elapsed_ms INTEGER NOT NULL DEFAULT 0 CHECK (idle_elapsed_ms>=0),
  idle_clock_at INTEGER,
  finished_at INTEGER,
  forfeited_at INTEGER,
  forfeit_reason TEXT,
  private_state_json TEXT NOT NULL CHECK (json_valid(private_state_json)),
  version INTEGER NOT NULL DEFAULT 1 CHECK (version>0),
  PRIMARY KEY (game_id,user_id),
  UNIQUE (game_id,seat_no),
  FOREIGN KEY (user_id,control_device_id) REFERENCES devices(user_id,id),
  CHECK (opened_safe<=remaining_safe_at_start),
  CHECK ((initial_health IS NULL)=(remaining_health IS NULL))
) STRICT;

-- 一个账号最多一个正式每日尝试。
CREATE TABLE active_competitions (
  user_id TEXT PRIMARY KEY,
  game_id TEXT NOT NULL,
  kind TEXT NOT NULL CHECK (kind IN ('daily_ranked')),
  acquired_at INTEGER NOT NULL,
  FOREIGN KEY (game_id,kind) REFERENCES online_games(id,kind),
  FOREIGN KEY (game_id,user_id) REFERENCES online_seats(game_id,user_id)
) STRICT;
CREATE TRIGGER competition_requires_account BEFORE INSERT ON active_competitions
WHEN NOT EXISTS (SELECT 1 FROM users WHERE id=NEW.user_id AND kind='registered' AND status='active')
BEGIN SELECT RAISE(ABORT,'registered active account required'); END;

CREATE TABLE online_commands (
  game_id TEXT NOT NULL,
  user_id TEXT NOT NULL,
  command_id TEXT NOT NULL,
  control_epoch INTEGER NOT NULL CHECK (control_epoch>0),
  client_seq INTEGER NOT NULL CHECK (client_seq>0),
  request_hash TEXT NOT NULL CHECK (length(request_hash)=64),
  received_at INTEGER NOT NULL,
  receipt_json TEXT NOT NULL CHECK (json_valid(receipt_json)),
  PRIMARY KEY (game_id,user_id,command_id),
  UNIQUE (game_id,user_id,control_epoch,client_seq),
  FOREIGN KEY (game_id,user_id) REFERENCES online_seats(game_id,user_id)
) STRICT;
CREATE TABLE online_events (
  global_seq INTEGER PRIMARY KEY AUTOINCREMENT,
  game_id TEXT NOT NULL REFERENCES online_games(id),
  game_seq INTEGER NOT NULL CHECK (game_seq>0),
  actor_user_id TEXT,
  effective_at INTEGER NOT NULL,
  kind TEXT NOT NULL,
  event_hash TEXT NOT NULL CHECK (length(event_hash)=64),
  chain_hash TEXT NOT NULL CHECK (length(chain_hash)=64),
  state_hash TEXT NOT NULL CHECK (length(state_hash)=64),
  UNIQUE (game_id,game_seq),
  FOREIGN KEY (game_id,actor_user_id) REFERENCES online_seats(game_id,user_id)
) STRICT;
CREATE INDEX online_events_time ON online_events(game_id,effective_at,game_seq);

CREATE TABLE online_results (
  game_id TEXT NOT NULL REFERENCES online_games(id),
  revision INTEGER NOT NULL CHECK (revision>0),
  kind TEXT NOT NULL CHECK (kind IN ('completion','time_limit','abandoned','invalid','daily_result')),
  winner_user_id TEXT,
  reason TEXT NOT NULL,
  facts_json TEXT NOT NULL CHECK (json_valid(facts_json)),
  effective_at INTEGER NOT NULL,
  recorded_at INTEGER NOT NULL,
  PRIMARY KEY (game_id,revision),
  FOREIGN KEY (game_id,winner_user_id) REFERENCES online_seats(game_id,user_id),
  CHECK (kind <> 'invalid' OR winner_user_id IS NULL)
) STRICT;
CREATE TRIGGER online_results_append_only BEFORE UPDATE ON online_results
BEGIN SELECT RAISE(ABORT,'append a result revision'); END;

CREATE TABLE account_restrictions (
  user_id TEXT NOT NULL REFERENCES users(id),
  scope TEXT NOT NULL CHECK (scope IN ('ranked')),
  until_at INTEGER,
  reason TEXT NOT NULL,
  source_json TEXT NOT NULL CHECK (json_valid(source_json)),
  version INTEGER NOT NULL CHECK (version>0),
  PRIMARY KEY (user_id,scope)
) STRICT;

CREATE TABLE daily_boards (
  challenge_date TEXT PRIMARY KEY,
  starts_at INTEGER NOT NULL,
  ends_at INTEGER NOT NULL CHECK (ends_at>starts_at),
  public_until INTEGER NOT NULL CHECK (public_until>ends_at),
  status TEXT NOT NULL CHECK (status IN ('open','frozen','invalid')),
  generation INTEGER NOT NULL DEFAULT 0 CHECK (generation>=0),
  frozen_at INTEGER,
  policy_revision TEXT NOT NULL REFERENCES rule_revisions(revision),
  CHECK ((status='frozen')=(frozen_at IS NOT NULL))
) STRICT;
CREATE TABLE daily_account_state (
  entry_no INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id TEXT NOT NULL REFERENCES users(id),
  challenge_date TEXT NOT NULL REFERENCES daily_boards(challenge_date),
  view_locked_at INTEGER,
  view_lock_order INTEGER,
  withdrawn_at INTEGER,
  version INTEGER NOT NULL CHECK (version>0),
  UNIQUE (user_id,challenge_date),
  CHECK ((view_locked_at IS NULL)=(view_lock_order IS NULL))
) STRICT;
CREATE TABLE daily_attempts (
  game_id TEXT PRIMARY KEY REFERENCES online_games(id),
  user_id TEXT NOT NULL,
  challenge_date TEXT NOT NULL,
  tier TEXT NOT NULL CHECK (tier IN ('easy','medium','expert')),
  entry_kind TEXT NOT NULL CHECK (entry_kind IN ('normal','ranked')),
  started_at INTEGER NOT NULL,
  finished_at INTEGER,
  finished_order INTEGER,
  elapsed_ms INTEGER CHECK (elapsed_ms>=0),
  initial_health INTEGER NOT NULL CHECK (initial_health BETWEEN 1 AND 3),
  damage_count INTEGER NOT NULL DEFAULT 0 CHECK (damage_count>=0),
  health_lost INTEGER NOT NULL DEFAULT 0 CHECK (health_lost>=0),
  status TEXT NOT NULL CHECK (status IN ('active','won','lost','abandoned','expired')),
  verification TEXT NOT NULL CHECK (verification IN ('pending','valid','revoked','invalid')),
  ranking_eligible INTEGER NOT NULL CHECK (ranking_eligible IN (0,1)),
  record_complete INTEGER NOT NULL CHECK (record_complete IN (0,1)),
  confirmed_at INTEGER,
  reward_bridge_run_id TEXT,
  record_device_id TEXT,
  record_manifest_json TEXT CHECK (record_manifest_json IS NULL OR json_valid(record_manifest_json)),
  UNIQUE (user_id,game_id),
  FOREIGN KEY (user_id,record_device_id) REFERENCES devices(user_id,id),
  FOREIGN KEY (game_id,user_id) REFERENCES online_seats(game_id,user_id),
  FOREIGN KEY (user_id,challenge_date) REFERENCES daily_account_state(user_id,challenge_date),
  FOREIGN KEY (challenge_date,tier) REFERENCES daily_challenges(challenge_date,tier),
  FOREIGN KEY (user_id,reward_bridge_run_id) REFERENCES game_runs(user_id,run_id),
  CHECK (ranking_eligible=0 OR (entry_kind='ranked' AND damage_count=0 AND health_lost=0)),
  CHECK ((finished_at IS NULL)=(finished_order IS NULL))
) STRICT;
CREATE INDEX daily_attempts_confirmation ON daily_attempts(challenge_date,verification,finished_at);
CREATE TABLE daily_view_entitlements (
  user_id TEXT NOT NULL,
  challenge_date TEXT NOT NULL,
  easy_game_id TEXT NOT NULL,
  medium_game_id TEXT NOT NULL,
  expert_game_id TEXT NOT NULL,
  granted_at INTEGER NOT NULL,
  revoked_at INTEGER,
  version INTEGER NOT NULL CHECK (version>0),
  PRIMARY KEY (user_id,challenge_date),
  FOREIGN KEY (user_id,challenge_date) REFERENCES daily_account_state(user_id,challenge_date),
  FOREIGN KEY (user_id,easy_game_id) REFERENCES daily_attempts(user_id,game_id),
  FOREIGN KEY (user_id,medium_game_id) REFERENCES daily_attempts(user_id,game_id),
  FOREIGN KEY (user_id,expert_game_id) REFERENCES daily_attempts(user_id,game_id),
  CHECK (easy_game_id<>medium_game_id AND easy_game_id<>expert_game_id AND medium_game_id<>expert_game_id)
) STRICT;
CREATE TABLE daily_bests (
  user_id TEXT NOT NULL,
  challenge_date TEXT NOT NULL,
  tier TEXT NOT NULL,
  game_id TEXT NOT NULL,
  PRIMARY KEY (user_id,challenge_date,tier),
  FOREIGN KEY (user_id,challenge_date) REFERENCES daily_account_state(user_id,challenge_date),
  FOREIGN KEY (user_id,game_id) REFERENCES daily_attempts(user_id,game_id),
  CHECK (tier IN ('easy','medium','expert'))
) STRICT;
CREATE TRIGGER best_requires_valid_attempt BEFORE INSERT ON daily_bests
WHEN NOT EXISTS (
  SELECT 1 FROM daily_attempts a JOIN daily_boards d ON d.challenge_date=a.challenge_date
  WHERE a.game_id=NEW.game_id AND a.user_id=NEW.user_id AND a.challenge_date=NEW.challenge_date AND a.tier=NEW.tier
    AND a.status='won' AND a.verification='valid' AND a.ranking_eligible=1 AND a.record_complete=1
    AND a.finished_at>=d.starts_at AND a.finished_at<d.ends_at
    AND a.confirmed_at<d.ends_at AND d.status='open'
    AND CAST(strftime('%s','now') AS INTEGER)*1000<d.ends_at
)
BEGIN SELECT RAISE(ABORT,'valid zero damage ranked attempt required'); END;
CREATE TRIGGER best_update_requires_valid_attempt BEFORE UPDATE ON daily_bests
WHEN NOT EXISTS (
  SELECT 1 FROM daily_attempts a JOIN daily_boards d ON d.challenge_date=a.challenge_date
  WHERE a.game_id=NEW.game_id AND a.user_id=NEW.user_id AND a.challenge_date=NEW.challenge_date AND a.tier=NEW.tier
    AND a.status='won' AND a.verification='valid' AND a.ranking_eligible=1 AND a.record_complete=1
    AND a.finished_at>=d.starts_at AND a.finished_at<d.ends_at
    AND a.confirmed_at<d.ends_at AND d.status='open'
    AND CAST(strftime('%s','now') AS INTEGER)*1000<d.ends_at
)
BEGIN SELECT RAISE(ABORT,'valid zero damage ranked attempt required'); END;

CREATE TABLE daily_rank_revisions (
  challenge_date TEXT NOT NULL REFERENCES daily_boards(challenge_date),
  generation INTEGER NOT NULL CHECK (generation>0),
  reason TEXT NOT NULL,
  created_at INTEGER NOT NULL,
  PRIMARY KEY (challenge_date,generation)
) STRICT;
CREATE TABLE daily_rankings (
  challenge_date TEXT NOT NULL,
  board_kind TEXT NOT NULL CHECK (board_kind IN ('easy','medium','expert','overall')),
  generation INTEGER NOT NULL,
  user_id TEXT NOT NULL,
  rank INTEGER NOT NULL CHECK (rank BETWEEN 1 AND 10),
  total_ms INTEGER NOT NULL CHECK (total_ms>=0),
  formed_at INTEGER NOT NULL,
  entry_no INTEGER NOT NULL REFERENCES daily_account_state(entry_no),
  easy_game_id TEXT,
  medium_game_id TEXT,
  expert_game_id TEXT,
  PRIMARY KEY (challenge_date,generation,board_kind,user_id),
  UNIQUE (challenge_date,generation,board_kind,rank),
  FOREIGN KEY (challenge_date,generation) REFERENCES daily_rank_revisions(challenge_date,generation),
  FOREIGN KEY (user_id,easy_game_id) REFERENCES daily_attempts(user_id,game_id),
  FOREIGN KEY (user_id,medium_game_id) REFERENCES daily_attempts(user_id,game_id),
  FOREIGN KEY (user_id,expert_game_id) REFERENCES daily_attempts(user_id,game_id),
  CHECK (
    (board_kind='easy' AND easy_game_id IS NOT NULL AND medium_game_id IS NULL AND expert_game_id IS NULL)
    OR (board_kind='medium' AND easy_game_id IS NULL AND medium_game_id IS NOT NULL AND expert_game_id IS NULL)
    OR (board_kind='expert' AND easy_game_id IS NULL AND medium_game_id IS NULL AND expert_game_id IS NOT NULL)
    OR (board_kind='overall' AND easy_game_id IS NOT NULL AND medium_game_id IS NOT NULL AND expert_game_id IS NOT NULL
      AND easy_game_id<>medium_game_id AND easy_game_id<>expert_game_id AND medium_game_id<>expert_game_id)
  )
) STRICT;

-- 录像正文/播放块只存Redis并带TTL;本表及上传表只存元数据。
-- 同一个game_id的录像仅存一份;单项榜与综合榜引用共享,不接受个人收藏上传。
CREATE TABLE restricted_segments (
  id TEXT PRIMARY KEY,
  game_id TEXT NOT NULL UNIQUE REFERENCES daily_attempts(game_id),
  version INTEGER NOT NULL CHECK (version>0),
  expires_at INTEGER NOT NULL,
  codec TEXT NOT NULL CHECK (codec='gzip'),
  redis_key TEXT NOT NULL UNIQUE CHECK (length(redis_key)>0),
  compressed_bytes INTEGER NOT NULL CHECK (compressed_bytes BETWEEN 1 AND 1048576),
  storage_expires_at INTEGER NOT NULL,
  uncompressed_bytes INTEGER NOT NULL CHECK (uncompressed_bytes BETWEEN 1 AND 8388608),
  sha256 TEXT NOT NULL CHECK (length(sha256)=64),
  validation_state TEXT NOT NULL CHECK (validation_state IN ('pending','valid','invalid')),
  playback_state TEXT NOT NULL CHECK (playback_state IN ('available','temporary_error','expired','removed')),
  created_at INTEGER NOT NULL,
  CHECK (storage_expires_at>created_at AND storage_expires_at<=expires_at)
) STRICT;
CREATE TABLE restricted_chunks (
  segment_id TEXT NOT NULL REFERENCES restricted_segments(id),
  chunk_index INTEGER NOT NULL CHECK (chunk_index>=0),
  from_ms INTEGER NOT NULL CHECK (from_ms>=0),
  to_ms INTEGER NOT NULL CHECK (to_ms>=from_ms),
  redis_key TEXT NOT NULL UNIQUE CHECK (length(redis_key)>0),
  byte_length INTEGER NOT NULL CHECK (byte_length BETWEEN 1 AND 8388608),
  expires_at INTEGER NOT NULL CHECK (expires_at>0),
  sha256 TEXT NOT NULL CHECK (length(sha256)=64),
  PRIMARY KEY (segment_id,chunk_index)
) STRICT;

CREATE TABLE watch_sessions (
  id TEXT PRIMARY KEY,
  user_id TEXT REFERENCES users(id),
  auth_session_id TEXT REFERENCES sessions(id),
  anonymous_id_hash TEXT,
  replay_segment_id TEXT NOT NULL REFERENCES restricted_segments(id),
  permission_epoch INTEGER NOT NULL CHECK (permission_epoch>0),
  state TEXT NOT NULL CHECK (state IN ('waiting','consent_required','prepared','playing','uncertain','ended')),
  created_at INTEGER NOT NULL,
  expires_at INTEGER NOT NULL,
  ended_at INTEGER,
  UNIQUE (id,user_id),
  FOREIGN KEY (user_id,auth_session_id) REFERENCES sessions(user_id,id),
  CHECK ((user_id IS NOT NULL AND auth_session_id IS NOT NULL AND anonymous_id_hash IS NULL)
      OR (user_id IS NULL AND auth_session_id IS NULL AND anonymous_id_hash IS NOT NULL))
) STRICT;
CREATE TABLE watch_exposures (
  id TEXT PRIMARY KEY,
  watch_session_id TEXT NOT NULL REFERENCES watch_sessions(id),
  user_id TEXT NOT NULL,
  challenge_date TEXT NOT NULL,
  permission_epoch INTEGER NOT NULL CHECK (permission_epoch>0),
  state TEXT NOT NULL CHECK (state IN ('prepared','released','rendered','cancelled','uncertain')),
  prepared_at INTEGER NOT NULL,
  released_at INTEGER,
  rendered_at INTEGER,
  release_order INTEGER,
  render_receipt_hash TEXT,
  FOREIGN KEY (watch_session_id,user_id) REFERENCES watch_sessions(id,user_id),
  FOREIGN KEY (user_id,challenge_date) REFERENCES daily_account_state(user_id,challenge_date),
  CHECK (state NOT IN ('released','rendered','uncertain') OR released_at IS NOT NULL),
  CHECK (state<>'rendered' OR rendered_at IS NOT NULL)
) STRICT;
CREATE INDEX unresolved_exposure ON watch_exposures(user_id,challenge_date,state);

CREATE TABLE moderation_cases (
  id TEXT PRIMARY KEY,
  reporter_user_id TEXT REFERENCES users(id),
  resource_type TEXT NOT NULL CHECK (resource_type IN ('game','ranking','account')),
  resource_id TEXT NOT NULL,
  kind TEXT NOT NULL CHECK (kind IN ('report','appeal','platform_incident','deletion')),
  status TEXT NOT NULL CHECK (status IN ('open','reviewing','resolved','dismissed')),
  reason TEXT NOT NULL,
  resolution_json TEXT CHECK (resolution_json IS NULL OR json_valid(resolution_json)),
  created_at INTEGER NOT NULL,
  resolved_at INTEGER
) STRICT;
-- 在线HTTP命令也需要幂等,不能借私有sync_operations发比赛结果。
CREATE TABLE online_http_receipts (
  user_id TEXT NOT NULL REFERENCES users(id),
  route_scope TEXT NOT NULL,
  idempotency_key TEXT NOT NULL,
  request_hash TEXT NOT NULL CHECK (length(request_hash)=64),
  response_json TEXT NOT NULL CHECK (json_valid(response_json)),
  created_at INTEGER NOT NULL,
  PRIMARY KEY (user_id,route_scope,idempotency_key)
) STRICT;

-- 每日地图只由服务器生成;公开目录不得包含此表的私有正文或种子。
CREATE TABLE daily_puzzles (
  challenge_date TEXT NOT NULL,
  tier TEXT NOT NULL,
  generator_revision TEXT NOT NULL,
  validator_revision TEXT NOT NULL,
  private_map_json TEXT NOT NULL CHECK (json_valid(private_map_json)),
  map_hash TEXT NOT NULL CHECK (length(map_hash)=64),
  no_guess_required INTEGER NOT NULL CHECK (no_guess_required IN (0,1)),
  validation_report_json TEXT NOT NULL CHECK (json_valid(validation_report_json)),
  generated_at INTEGER NOT NULL,
  published_at INTEGER NOT NULL,
  PRIMARY KEY (challenge_date,tier),
  FOREIGN KEY (challenge_date,tier) REFERENCES daily_challenges(challenge_date,tier)
) STRICT;
CREATE TRIGGER published_puzzle_immutable BEFORE UPDATE ON daily_puzzles
BEGIN SELECT RAISE(ABORT,'published puzzle immutable'); END;
CREATE TRIGGER published_daily_mapping_immutable BEFORE UPDATE ON daily_challenges
BEGIN SELECT RAISE(ABORT,'daily mapping immutable'); END;

-- candidate只表示允许上传;未核验者不得占据排名。
CREATE TABLE daily_score_submissions (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  challenge_date TEXT NOT NULL,
  board_kind TEXT NOT NULL CHECK (board_kind IN ('easy','medium','expert','overall')),
  easy_game_id TEXT,
  medium_game_id TEXT,
  expert_game_id TEXT,
  total_ms INTEGER NOT NULL CHECK (total_ms>=0),
  formed_at INTEGER NOT NULL,
  entry_no INTEGER NOT NULL REFERENCES daily_account_state(entry_no),
  compared_generation INTEGER NOT NULL CHECK (compared_generation>=0),
  status TEXT NOT NULL CHECK (status IN ('not_candidate','awaiting_upload','verifying','published','outpaced','rejected','expired')),
  manifest_hash TEXT NOT NULL CHECK (length(manifest_hash)=64),
  created_at INTEGER NOT NULL,
  expires_at INTEGER NOT NULL CHECK (expires_at>created_at),
  published_generation INTEGER,
  FOREIGN KEY (user_id,challenge_date) REFERENCES daily_account_state(user_id,challenge_date),
  FOREIGN KEY (user_id,easy_game_id) REFERENCES daily_attempts(user_id,game_id),
  FOREIGN KEY (user_id,medium_game_id) REFERENCES daily_attempts(user_id,game_id),
  FOREIGN KEY (user_id,expert_game_id) REFERENCES daily_attempts(user_id,game_id),
  FOREIGN KEY (challenge_date,published_generation) REFERENCES daily_rank_revisions(challenge_date,generation),
  UNIQUE (id,user_id),
  CHECK (easy_game_id<>medium_game_id AND easy_game_id<>expert_game_id AND medium_game_id<>expert_game_id),
  CHECK ((status='published')=(published_generation IS NOT NULL)),
  CHECK (
    (board_kind='easy' AND easy_game_id IS NOT NULL AND medium_game_id IS NULL AND expert_game_id IS NULL)
    OR (board_kind='medium' AND easy_game_id IS NULL AND medium_game_id IS NOT NULL AND expert_game_id IS NULL)
    OR (board_kind='expert' AND easy_game_id IS NULL AND medium_game_id IS NULL AND expert_game_id IS NOT NULL)
    OR (board_kind='overall' AND easy_game_id IS NOT NULL AND medium_game_id IS NOT NULL AND expert_game_id IS NOT NULL
      AND easy_game_id<>medium_game_id AND easy_game_id<>expert_game_id AND medium_game_id<>expert_game_id)
  )
) STRICT;
CREATE UNIQUE INDEX one_pending_daily_submission ON daily_score_submissions(user_id,challenge_date,board_kind)
WHERE status IN ('awaiting_upload','verifying');

CREATE TABLE daily_replay_uploads (
  id TEXT PRIMARY KEY,
  submission_id TEXT NOT NULL,
  user_id TEXT NOT NULL,
  game_id TEXT NOT NULL,
  codec TEXT NOT NULL CHECK (codec='gzip'),
  compressed_sha256 TEXT NOT NULL CHECK (length(compressed_sha256)=64),
  content_sha256 TEXT NOT NULL CHECK (length(content_sha256)=64),
  compressed_bytes INTEGER NOT NULL CHECK (compressed_bytes BETWEEN 1 AND 1048576),
  uncompressed_bytes INTEGER NOT NULL CHECK (uncompressed_bytes BETWEEN 1 AND 8388608),
  redis_key TEXT NOT NULL UNIQUE CHECK (length(redis_key)>0),
  state TEXT NOT NULL CHECK (state IN ('granted','received','valid','invalid','expired')),
  expires_at INTEGER NOT NULL,
  received_at INTEGER,
  verified_at INTEGER,
  UNIQUE (submission_id,game_id),
  FOREIGN KEY (submission_id,user_id) REFERENCES daily_score_submissions(id,user_id),
  FOREIGN KEY (user_id,game_id) REFERENCES daily_attempts(user_id,game_id),
  CHECK (state NOT IN ('received','valid') OR received_at IS NOT NULL),
  CHECK (state<>'valid' OR verified_at IS NOT NULL)
) STRICT;
CREATE TRIGGER upload_requires_candidate BEFORE INSERT ON daily_replay_uploads
WHEN NOT EXISTS (
  SELECT 1 FROM daily_score_submissions s JOIN daily_boards d USING(challenge_date)
  WHERE s.id=NEW.submission_id AND s.user_id=NEW.user_id AND s.status='awaiting_upload'
    AND NEW.game_id IN (s.easy_game_id,s.medium_game_id,s.expert_game_id)
    AND NEW.expires_at<=s.expires_at AND s.expires_at<=d.ends_at
    AND d.status='open' AND CAST(strftime('%s','now') AS INTEGER)*1000<NEW.expires_at
)
BEGIN SELECT RAISE(ABORT,'active candidate upload grant required'); END;

-- 历史撤回/违规只能追加注记或撤播放,不覆盖原rank或递补。
CREATE TABLE daily_rank_annotations (
  id TEXT PRIMARY KEY,
  challenge_date TEXT NOT NULL,
  generation INTEGER NOT NULL,
  board_kind TEXT NOT NULL CHECK (board_kind IN ('easy','medium','expert','overall')),
  rank INTEGER NOT NULL,
  kind TEXT NOT NULL CHECK (kind IN ('disqualified','withdrawn','identity_removed','replay_removed','platform_incident')),
  reason TEXT NOT NULL,
  created_at INTEGER NOT NULL,
  FOREIGN KEY (challenge_date,generation,board_kind,rank) REFERENCES daily_rankings(challenge_date,generation,board_kind,rank)
) STRICT;

-- 防止冻结worker延迟时继续发布;数据库时钟是额外护栏,调度器也必须在写事务提交前检查日期。
CREATE TRIGGER rank_revision_before_cutoff BEFORE INSERT ON daily_rank_revisions
WHEN NOT EXISTS (
  SELECT 1 FROM daily_boards d WHERE d.challenge_date=NEW.challenge_date AND d.status='open'
    AND NEW.generation=d.generation+1
    AND NEW.created_at>=d.starts_at AND NEW.created_at<d.ends_at
    AND CAST(strftime('%s','now') AS INTEGER)*1000<d.ends_at
)
BEGIN SELECT RAISE(ABORT,'daily board closed'); END;
CREATE TRIGGER ranking_before_cutoff BEFORE INSERT ON daily_rankings
WHEN NOT EXISTS (
  SELECT 1 FROM daily_boards d WHERE d.challenge_date=NEW.challenge_date AND d.status='open'
    AND NEW.generation=d.generation+1
    AND CAST(strftime('%s','now') AS INTEGER)*1000<d.ends_at
)
BEGIN SELECT RAISE(ABORT,'daily board closed'); END;
CREATE TRIGGER rank_revision_immutable BEFORE UPDATE ON daily_rank_revisions
BEGIN SELECT RAISE(ABORT,'ranking revision immutable'); END;
CREATE TRIGGER rank_revision_no_delete BEFORE DELETE ON daily_rank_revisions
BEGIN SELECT RAISE(ABORT,'ranking revision retained'); END;
CREATE TRIGGER ranking_immutable BEFORE UPDATE ON daily_rankings
BEGIN SELECT RAISE(ABORT,'ranking snapshot immutable'); END;
CREATE TRIGGER ranking_no_delete BEFORE DELETE ON daily_rankings
BEGIN SELECT RAISE(ABORT,'ranking snapshot retained'); END;
CREATE TRIGGER frozen_board_immutable BEFORE UPDATE ON daily_boards
WHEN OLD.status='frozen'
BEGIN SELECT RAISE(ABORT,'frozen board immutable'); END;
CREATE TRIGGER board_deadline_immutable BEFORE UPDATE ON daily_boards
WHEN NEW.starts_at<>OLD.starts_at OR NEW.ends_at<>OLD.ends_at OR NEW.challenge_date<>OLD.challenge_date
BEGIN SELECT RAISE(ABORT,'daily deadline immutable'); END;
CREATE TRIGGER board_generation_before_cutoff BEFORE UPDATE OF generation ON daily_boards
WHEN NEW.generation<>OLD.generation AND (
  NEW.generation<>OLD.generation+1 OR OLD.status<>'open' OR CAST(strftime('%s','now') AS INTEGER)*1000>=OLD.ends_at
  OR NOT EXISTS (SELECT 1 FROM daily_rank_revisions r WHERE r.challenge_date=NEW.challenge_date AND r.generation=NEW.generation)
)
BEGIN SELECT RAISE(ABORT,'cannot publish ranking generation'); END;
CREATE TRIGGER board_freeze_after_cutoff BEFORE UPDATE OF status ON daily_boards
WHEN NEW.status='frozen' AND (NEW.frozen_at<OLD.ends_at OR CAST(strftime('%s','now') AS INTEGER)*1000<OLD.ends_at)
BEGIN SELECT RAISE(ABORT,'cannot freeze before cutoff'); END;

CREATE TRIGGER terminal_attempt_facts_immutable BEFORE UPDATE ON daily_attempts
WHEN OLD.status<>'active' AND (
  NEW.game_id<>OLD.game_id OR NEW.user_id<>OLD.user_id OR NEW.challenge_date<>OLD.challenge_date
  OR NEW.tier<>OLD.tier OR NEW.entry_kind<>OLD.entry_kind OR NEW.started_at<>OLD.started_at
  OR NEW.finished_at IS NOT OLD.finished_at OR NEW.finished_order IS NOT OLD.finished_order
  OR NEW.elapsed_ms IS NOT OLD.elapsed_ms OR NEW.initial_health<>OLD.initial_health
  OR NEW.damage_count<>OLD.damage_count OR NEW.health_lost<>OLD.health_lost OR NEW.status<>OLD.status
)
BEGIN SELECT RAISE(ABORT,'terminal attempt facts immutable'); END;

COMMIT;