fix: lock server starting chapter on first initialization
This commit is contained in:
@@ -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。不同难度分别记录任务进度与奖励,重新连接后继续原有选择。
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user