diff --git a/GAME_CONFIGURATION.md b/GAME_CONFIGURATION.md index 6f41099..7875d99 100644 --- a/GAME_CONFIGURATION.md +++ b/GAME_CONFIGURATION.md @@ -2,7 +2,7 @@ `game.json` 是服务端统一的游戏规则配置入口。后续可配置的游戏规则在此按功能分组扩展;认证、资源源、版本清单仍分别使用现有配置文件。 -发布部署时,将文件放在 `bd2server.exe`(Linux/macOS 为 `bd2server`)旁。开发入口读取仓库根目录的同名文件。修改后重启服务端生效,客户端无需配置此文件。发布包只在服务端包中提供 `game.json` 和本指南。 +发布部署时,将文件放在 `bd2server.exe`(Linux/macOS 为 `bd2server`)旁。开发入口读取仓库根目录的同名文件。可修改的规则在重启后生效,起始章节只在首次初始化时选择。客户端无需配置此文件。发布包只在服务端包中提供 `game.json` 和本指南。 默认内容: @@ -29,16 +29,20 @@ 开关仅作用于 UR 必得装备券。新增装备沿用同星级原有装备的单件权重,原有星级权重规则保持不变;加入候选后,同星级内的概率按扩展后的权重总和计算。该设置只改变券池候选,不会直接发放装备。专武绑定角色,同一角色的多个服装共用专武。 -## 新玩家的起始章节 +## 服务器的起始章节 `story.start_pack_id` 接受整数 `21` 或 `1`,省略时默认 `21`。 -| 值 | 新账号的开局 | +| 值 | 本服玩家的开局 | |---|---| | `21`(默认) | 从当前客户端默认的店长篇开始,使用该篇的任务和教学流程。 | | `1` | 从旧主线第一章开始,使用当前 GameData 中该章的开场剧情、初始任务及购卡奖励。 | -此设置只在创建完整新账号存档时使用。起始章节会保存到该账号,修改配置后不会重置已有玩家、移动其当前位置或改变其选中的任务难度。不要通过删除玩家数据库来切换开局;这会删除玩家存档。 +请在服务器首次启动前确定此值。第一次成功初始化时,服务端会在 SQLite 中持久保存服务器的起始章节;此后配置必须与保存值一致。改成另一个章节会拒绝启动,并在日志中明确提示原值与配置值,不会覆盖原设置或玩家进度。首次启动失败并回滚时不会锁定选择,可以修正配置后重试。 + +这项限制只针对 `story.start_pack_id`;联动专武等其他游戏规则仍可修改。升级和恢复服务器时应一起保留配置与数据库,不能通过删除数据库来切换开局,这会删除玩家存档。 + +后续官方若将旧主线与店长篇重构合并为一条剧情线,再重新评估此限制与迁移方案。重构发布日期尚未确认,可能延续到 2027 年;当前不预设合并时间或章节连接。 起始章节与教学 seed 分开处理,选择 `1` 不会把店长篇任务编号或剧情队伍替换进第一章。章节中的任务顺序、剧情阵容、内容票券以及允许的难度均读取当前 GameData。不同难度分别记录任务进度与奖励,重新连接后继续原有选择。 diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index e7643ee..45483d2 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -289,6 +289,10 @@ func serve(args []string) (serveErr error) { serveErr = errors.Join(serveErr, rollbackErr) } }() + startingPackID, err := stateRepository.LockStartingPack(gameRules.Story.StartPackID, initializeAccount) + if err != nil { + return fmt.Errorf("server starting chapter policy: %w", err) + } serverConfig, err := readonly.Load(filepath.Clean(*readonlySeed)) if err != nil { return fmt.Errorf("load readonly server configuration: %w", err) @@ -447,7 +451,7 @@ func serve(args []string) (serveErr error) { if err != nil { return fmt.Errorf("load world state: %w", err) } - if err := worldService.ConfigureStartPack(gameRules.Story.StartPackID, initializeAccount); err != nil { + if err := worldService.ConfigureStartPack(startingPackID, initializeAccount); err != nil { return fmt.Errorf("configure account starting chapter: %w", err) } if err := login.AttachLastPlayedPack(worldService); err != nil { diff --git a/go/internal/server/accountstate/server_policy.go b/go/internal/server/accountstate/server_policy.go new file mode 100644 index 0000000..5df7b1d --- /dev/null +++ b/go/internal/server/accountstate/server_policy.go @@ -0,0 +1,61 @@ +package accountstate + +import ( + "database/sql" + "errors" + "fmt" + "strconv" +) + +const startingPackMetadataKey = "server_start_pack_id" + +// LockStartingPack checks the permanent server starting-pack policy inside the +// active startup operation. Only an empty database may initialize the policy; +// the operation's commit makes the first successful startup's choice durable. +func (r *Repository) LockStartingPack(configured int, initialize bool) (int, error) { + if configured != 1 && configured != 21 { + return 0, fmt.Errorf("accountstate: invalid story.start_pack_id %d (want 1 or 21)", configured) + } + if err := r.Check(); err != nil { + return 0, err + } + r.activeMu.RLock() + defer r.activeMu.RUnlock() + if r.active == nil { + return 0, errors.New("accountstate: starting pack policy requires an active startup operation") + } + t := r.active + t.mu.Lock() + defer t.mu.Unlock() + if t.done { + return 0, ErrClosed + } + var raw string + err := t.tx.QueryRow(`SELECT value FROM metadata WHERE key=?`, startingPackMetadataKey).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + var populated bool + if err := t.tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM domain_state) OR EXISTS(SELECT 1 FROM domain_entry)`).Scan(&populated); err != nil { + return 0, fmt.Errorf("accountstate: inspect starting pack initialization: %w", err) + } + if !initialize || populated { + return 0, errors.New("accountstate: server_start_pack_id is missing; existing state requires explicit repair") + } + if _, err := t.tx.Exec(`INSERT INTO metadata(key,value) VALUES(?,?)`, startingPackMetadataKey, strconv.Itoa(configured)); err != nil { + return 0, fmt.Errorf("accountstate: initialize starting pack policy: %w", err) + } + // Metadata changes do not mutate domain memory, so a rollback need not + // trigger the domain-memory recovery fence. + return configured, nil + } + if err != nil { + return 0, fmt.Errorf("accountstate: read starting pack policy: %w", err) + } + locked, err := strconv.Atoi(raw) + if err != nil || (raw != "1" && raw != "21") { + return 0, fmt.Errorf("accountstate: invalid server_start_pack_id %q; explicit repair required", raw) + } + if locked != configured { + return 0, fmt.Errorf("accountstate: story.start_pack_id %d conflicts with permanent server_start_pack_id %d", configured, locked) + } + return locked, nil +} diff --git a/go/internal/server/accountstate/server_policy_test.go b/go/internal/server/accountstate/server_policy_test.go new file mode 100644 index 0000000..401fa7a --- /dev/null +++ b/go/internal/server/accountstate/server_policy_test.go @@ -0,0 +1,136 @@ +package accountstate + +import ( + "context" + "database/sql" + "errors" + "testing" +) + +func TestStartingPackPolicyPersistsAndRejectsChanges(t *testing.T) { + r, path := openTestRepository(t) + if _, err := r.LockStartingPack(21, true); err == nil { + t.Fatal("accepted policy outside startup operation") + } + op, err := r.BeginOperation() + if err != nil { + t.Fatal(err) + } + if got, err := r.LockStartingPack(21, true); err != nil || got != 21 { + t.Fatalf("lock=%d: %v", got, err) + } + if err := op.Commit(); err != nil { + t.Fatal(err) + } + if err := r.Close(); err != nil { + t.Fatal(err) + } + r, err = Open(path) + if err != nil { + t.Fatal(err) + } + defer r.Close() + op, err = r.BeginOperation() + if err != nil { + t.Fatal(err) + } + if got, err := r.LockStartingPack(21, false); err != nil || got != 21 { + t.Fatalf("reopened lock=%d: %v", got, err) + } + if _, err := r.LockStartingPack(1, true); err == nil { + t.Fatal("accepted changed policy") + } + if err := op.Commit(); err != nil { + t.Fatal(err) + } + var raw string + if err := r.db.QueryRow(`SELECT value FROM metadata WHERE key=?`, startingPackMetadataKey).Scan(&raw); err != nil || raw != "21" { + t.Fatalf("policy=%q: %v", raw, err) + } +} + +func TestStartingPackPolicyRollbackAllowsAnotherChoice(t *testing.T) { + r, path := openTestRepository(t) + op, err := r.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, err := r.LockStartingPack(21, true); err != nil { + t.Fatal(err) + } + if err := op.Rollback(); err != nil { + t.Fatal(err) + } + var raw string + if err := r.db.QueryRow(`SELECT value FROM metadata WHERE key=?`, startingPackMetadataKey).Scan(&raw); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("rolled back policy survived: %q, %v", raw, err) + } + if err := r.Close(); err != nil { + t.Fatal(err) + } + r, err = Open(path) + if err != nil { + t.Fatal(err) + } + defer r.Close() + op, err = r.BeginOperation() + if err != nil { + t.Fatal(err) + } + if got, err := r.LockStartingPack(1, true); err != nil || got != 1 { + t.Fatalf("retry lock=%d: %v", got, err) + } + if err := op.Commit(); err != nil { + t.Fatal(err) + } +} + +func TestStartingPackPolicyMissingExistingRejectsInitialization(t *testing.T) { + r, _ := openTestRepository(t) + if _, err := r.SaveContext(context.Background(), "progress", []byte("{}")); err != nil { + t.Fatal(err) + } + op, err := r.BeginOperation() + if err != nil { + t.Fatal(err) + } + defer op.Rollback() + for _, initialize := range []bool{false, true} { + if _, err := r.LockStartingPack(1, initialize); err == nil { + t.Fatalf("accepted missing existing policy initialize=%v", initialize) + } + } +} + +func TestStartingPackPolicyRejectsInvalidValues(t *testing.T) { + for _, raw := range []string{"0", "2", "abc", "01", "21 "} { + t.Run(raw, func(t *testing.T) { + r, _ := openTestRepository(t) + if _, err := r.db.Exec(`INSERT INTO metadata(key,value) VALUES(?,?)`, startingPackMetadataKey, raw); err != nil { + t.Fatal(err) + } + op, err := r.BeginOperation() + if err != nil { + t.Fatal(err) + } + defer op.Rollback() + if _, err := r.LockStartingPack(1, false); err == nil { + t.Fatal("accepted invalid stored value") + } + }) + } + r, _ := openTestRepository(t) + op, err := r.BeginOperation() + if err != nil { + t.Fatal(err) + } + defer op.Rollback() + for _, configured := range []int{0, 2, -1} { + if _, err := r.LockStartingPack(configured, true); err == nil { + t.Fatalf("accepted invalid configured value %d", configured) + } + } + if _, err := r.LockStartingPack(1, false); err == nil { + t.Fatal("initialized empty DB without authorization") + } +} diff --git a/go/internal/server/gameconfig/config.go b/go/internal/server/gameconfig/config.go index 6c97d63..a7d9e0f 100644 --- a/go/internal/server/gameconfig/config.go +++ b/go/internal/server/gameconfig/config.go @@ -19,8 +19,10 @@ type Config struct { Story StoryConfig `json:"story"` } -// StartPackID selects the entry chapter for newly initialized accounts. -// Existing accounts retain their persisted entry and selected chapter. +// StartPackID is fixed for the server by its first successful initialization. +// TODO: Revisit this restriction if the official story rework merges the old +// story and master packs into one progression. Its release date is unconfirmed; +// it may extend into 2027, so do not infer or implement a merged chain now. type StoryConfig struct { StartPackID int `json:"start_pack_id"` } diff --git a/go/internal/server/world/start_pack.go b/go/internal/server/world/start_pack.go index 6db8ce9..74bd509 100644 --- a/go/internal/server/world/start_pack.go +++ b/go/internal/server/world/start_pack.go @@ -35,6 +35,9 @@ func (s *Service) ConfigureStartPack(packID int, initialize bool) error { } } entry := s.state.StartPackID() + if entry != packID { + return fmt.Errorf("world: account starting chapter %d differs from locked server chapter %d", entry, packID) + } if _, ok := s.storyCatalog.Packs[entry]; !ok { return fmt.Errorf("world: invalid persisted starting chapter %d", entry) } diff --git a/go/internal/server/world/start_pack_test.go b/go/internal/server/world/start_pack_test.go index e695b65..0680a14 100644 --- a/go/internal/server/world/start_pack_test.go +++ b/go/internal/server/world/start_pack_test.go @@ -36,7 +36,10 @@ func TestStartingChapterPolicyPersistsWithoutReplacingTutorialSeed(t *testing.T) t.Fatal(err) } s.state = reopened - if err := s.ConfigureStartPack(21, false); err != nil { + if err := s.ConfigureStartPack(21, false); err == nil { + t.Fatal("accepted account inconsistent with server entry policy") + } + if err := s.ConfigureStartPack(1, false); err != nil { t.Fatal(err) } if s.startingPack() != 1 {