init
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/account"
|
||||
"bd2server/internal/battle"
|
||||
"bd2server/internal/bootstrap"
|
||||
"bd2server/internal/deck"
|
||||
"bd2server/internal/feature"
|
||||
"bd2server/internal/gacha"
|
||||
"bd2server/internal/gamedata"
|
||||
"bd2server/internal/introdb"
|
||||
"bd2server/internal/mail"
|
||||
"bd2server/internal/missions"
|
||||
"bd2server/internal/pictorial"
|
||||
"bd2server/internal/player"
|
||||
"bd2server/internal/progress"
|
||||
"bd2server/internal/readonly"
|
||||
"bd2server/internal/schedule"
|
||||
"bd2server/internal/session"
|
||||
"bd2server/internal/transport"
|
||||
"bd2server/internal/world"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
var err error
|
||||
switch os.Args[1] {
|
||||
case "serve":
|
||||
err = serve(os.Args[2:])
|
||||
case "patch-client":
|
||||
err = patchClient(os.Args[2:])
|
||||
case "state":
|
||||
err = stateCommand(os.Args[2:])
|
||||
case "help", "-h", "--help":
|
||||
usage()
|
||||
return
|
||||
default:
|
||||
err = fmt.Errorf("unknown command %q", os.Args[1])
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("command failed", "command", os.Args[1], "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func serve(args []string) error {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
listen := fs.String("listen", "127.0.0.1:8080", "local listen address")
|
||||
cdn := fs.String("cdn", "", "ServerData root (required)")
|
||||
gameData := fs.String("game-data", "", "versioned GameData root (required)")
|
||||
gameDataVersion := fs.String("game-data-version", "", "validated GameData version (required)")
|
||||
gameDataOrigin := fs.String("game-data-origin", "https://dl.bd2.pmang.cloud/GameData", "official repair source used only when local validation fails")
|
||||
accountSeed := fs.String("account-seed", `seed\v2_34_13\login_user.json`, "versioned local account seed")
|
||||
playerSeed := fs.String("player-seed", `seed\v2_34_13\starter_player.json`, "versioned starter inventory and characters")
|
||||
readonlySeed := fs.String("readonly-seed", `seed\v2_34_13\readonly.json`, "versioned server schedules and optional feature defaults")
|
||||
mailSeed := fs.String("mail-seed", `seed\v2_34_13\mail.json`, "versioned starter mailbox")
|
||||
stateFile := fs.String("state", `..\data\state\progress.json`, "local player progress save")
|
||||
deckSeed := fs.String("deck-seed", `seed\v2_34_13\decks.json`, "versioned starter deck")
|
||||
deckState := fs.String("deck-state", `..\data\state\deck.json`, "local deck and waypoint save")
|
||||
worldSeed := fs.String("world-seed", `seed\v2_34_13\world.json`, "versioned starter world")
|
||||
stateTool := fs.String("state-tool", "", "optional Haskell state tool override (release builds embed it)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *cdn == "" || *gameData == "" || *gameDataVersion == "" {
|
||||
return errors.New("serve requires --cdn, --game-data, and --game-data-version")
|
||||
}
|
||||
base := "http://" + *listen + "/game/"
|
||||
cfg := bootstrap.Config{
|
||||
BaseURL: base,
|
||||
CDNURL: "http://" + *listen + "/assets/ServerData",
|
||||
Version: bootstrap.ClientVersion,
|
||||
BundleVer: bootstrap.BundleVersion,
|
||||
GameDataURL: "http://" + *listen + "/assets/GameData",
|
||||
GameDataVer: *gameDataVersion,
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := os.Stat(*cdn); err != nil || !info.IsDir() {
|
||||
return fmt.Errorf("CDN directory is unavailable: %q", *cdn)
|
||||
}
|
||||
verifiedGameData, downloaded, err := gamedata.Ensure(context.Background(), nil, filepath.Clean(*gameData), *gameDataVersion, *gameDataOrigin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refuse to advertise unavailable or unverified GameData: %w", err)
|
||||
}
|
||||
if downloaded {
|
||||
slog.Info("repaired GameData from official CDN", "archive", verifiedGameData.ArchivePath, "entries", verifiedGameData.EntryCount)
|
||||
}
|
||||
login, err := account.Load(filepath.Clean(*accountSeed))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load local account seed: %w", err)
|
||||
}
|
||||
starter, err := player.Load(filepath.Clean(*playerSeed))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load starter player: %w", err)
|
||||
}
|
||||
regularGacha, err := gamedata.LoadRegularCostumeGacha(filepath.Clean(*gameData), *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load regular gacha GameData: %w", err)
|
||||
}
|
||||
if err := repairStateBeforeServe(filepath.Dir(filepath.Clean(*stateFile)), *stateTool, *gameDataVersion, regularGacha, starter); err != nil {
|
||||
return fmt.Errorf("state migration/repair: %w", err)
|
||||
}
|
||||
serverConfig, err := readonly.Load(filepath.Clean(*readonlySeed))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load readonly server configuration: %w", err)
|
||||
}
|
||||
mailbox, err := mail.Load(filepath.Clean(*mailSeed))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load starter mailbox: %w", err)
|
||||
}
|
||||
progressState, err := progress.OpenStore(*stateFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The current local save has completed the first-gacha tutorial gate.
|
||||
// Deriving this UI flag from authoritative story progress prevents a new
|
||||
// account from seeing ordinary pools before the tutorial unlocks them.
|
||||
login.SetFirstGacha(progressState.QuestCleared(37, 21))
|
||||
deckConfig, err := deck.LoadSeed(filepath.Clean(*deckSeed))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load starter deck: %w", err)
|
||||
}
|
||||
deckStateStore, err := deck.OpenStore(filepath.Clean(*deckState), deckConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load deck state: %w", err)
|
||||
}
|
||||
ownedItems, err := player.OpenInventory(filepath.Join(filepath.Dir(*stateFile), "items.json"), starter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load owned inventory: %w", err)
|
||||
}
|
||||
gold, freeJewelry, jewelry, mileage, err := login.SeedCurrencies()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read account seed currency: %w", err)
|
||||
}
|
||||
hopePowder, err := login.SeedHopePowder()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read account seed hope powder: %w", err)
|
||||
}
|
||||
wallet, err := player.OpenWallet(filepath.Join(filepath.Dir(*stateFile), "wallet.json"), player.Currency{
|
||||
Gold: gold, FreeJewelry: freeJewelry, Jewelry: jewelry, Mileage: mileage, HopePowder: hopePowder,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("load wallet state: %w", err)
|
||||
}
|
||||
if err := login.AttachCurrencies(wallet); err != nil {
|
||||
return fmt.Errorf("attach wallet to login: %w", err)
|
||||
}
|
||||
mailService, err := mail.OpenService(filepath.Join(filepath.Dir(*stateFile), "mail.json"), mailbox, ownedItems, wallet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load mail state: %w", err)
|
||||
}
|
||||
missionDesign, err := gamedata.LoadMissionDesign(filepath.Clean(*gameData), *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load mission GameData: %w", err)
|
||||
}
|
||||
missionService, err := missions.Open(filepath.Join(filepath.Dir(*stateFile), "missions.json"), missionDesign, ownedItems)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load mission state: %w", err)
|
||||
}
|
||||
if err := missionService.AttachWallet(wallet); err != nil {
|
||||
return fmt.Errorf("attach mission wallet: %w", err)
|
||||
}
|
||||
if err := missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 101}); err != nil {
|
||||
return fmt.Errorf("record daily login mission: %w", err)
|
||||
}
|
||||
if err := missionService.SetProgress(gamedata.MissionKey{GroupType: 1, GroupID: 2, ID: 201}, 1); err != nil {
|
||||
return fmt.Errorf("record weekly login mission: %w", err)
|
||||
}
|
||||
ownedEquipment, err := player.OpenEquipmentInventory(filepath.Join(filepath.Dir(*stateFile), "equipment.json"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load owned equipment: %w", err)
|
||||
}
|
||||
collection, err := player.OpenCollectionStore(filepath.Join(filepath.Dir(*stateFile), "collection.json"), starter.Costumes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load owned collection: %w", err)
|
||||
}
|
||||
infiniteGacha, err := gamedata.LoadInfiniteGacha(filepath.Clean(*gameData), *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load infinite gacha GameData: %w", err)
|
||||
}
|
||||
equipmentGacha, err := gamedata.LoadEquipmentGacha(filepath.Clean(*gameData), *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load equipment gacha GameData: %w", err)
|
||||
}
|
||||
gachaService, err := gacha.NewService(infiniteGacha, regularGacha, collection, wallet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gachaService.AttachInventory(ownedItems)
|
||||
gachaService.AttachEquipmentGacha(equipmentGacha, ownedEquipment)
|
||||
gachaService.AttachPreviewMission(func() error {
|
||||
return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 111})
|
||||
})
|
||||
worldService, err := world.Load(filepath.Clean(*worldSeed), filepath.Clean(*gameData), *gameDataVersion,
|
||||
filepath.Join(filepath.Dir(*stateFile), "characters.json"), progressState, starter, ownedEquipment, ownedItems, wallet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load world state: %w", err)
|
||||
}
|
||||
if err := collection.BindBaseCharacters(worldService.CharacterService().RawAll()); err != nil {
|
||||
return fmt.Errorf("bind base collection characters: %w", err)
|
||||
}
|
||||
if err := worldService.AttachCollection(collection); err != nil {
|
||||
return fmt.Errorf("attach gacha collection state: %w", err)
|
||||
}
|
||||
if err := worldService.AttachDecks(deckStateStore); err != nil {
|
||||
return fmt.Errorf("attach world deck state: %w", err)
|
||||
}
|
||||
if err := ownedEquipment.AttachCharacters(worldService.CharacterService()); err != nil {
|
||||
return fmt.Errorf("attach equipment character state: %w", err)
|
||||
}
|
||||
pictorialDesign, err := gamedata.LoadPictorialDesign(filepath.Clean(*gameData), *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load pictorial GameData: %w", err)
|
||||
}
|
||||
pictorialService := &pictorial.Service{Design: pictorialDesign, Owned: worldService}
|
||||
if err := worldService.CharacterService().AttachMaxHealth(pictorialService.MaxHealth); err != nil {
|
||||
return fmt.Errorf("attach pictorial character stats: %w", err)
|
||||
}
|
||||
battleService := battle.NewService(filepath.Clean(*gameData), *gameDataVersion, ownedItems)
|
||||
battleService.AttachTutorialWin(func() error {
|
||||
return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 113})
|
||||
})
|
||||
battleService.AttachPictorialBuffs(func() ([]gamedata.PictorialBuffStat, error) {
|
||||
_, buffs, err := pictorialService.Snapshot()
|
||||
return buffs, err
|
||||
})
|
||||
game, err := session.NewServerWithProgress(login, progressState,
|
||||
battleService,
|
||||
worldService,
|
||||
worldService.CharacterService(),
|
||||
progressState,
|
||||
deckStateStore,
|
||||
ownedItems,
|
||||
ownedEquipment,
|
||||
starter,
|
||||
mailService,
|
||||
gachaService,
|
||||
missionService,
|
||||
pictorialService,
|
||||
schedule.Version23413(),
|
||||
readonly.Service{Seed: serverConfig},
|
||||
feature.Service{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dispatcher := transport.Bootstrap{Config: cfg}
|
||||
handler := transport.HTTP{Dispatcher: dispatcher, Raw: game, CDNDir: filepath.Clean(*cdn), GameDataDir: filepath.Clean(*gameData)}.Handler()
|
||||
server := &http.Server{
|
||||
Addr: *listen,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 20 * time.Second,
|
||||
WriteTimeout: 20 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
slog.Info("BD2 local server listening", "address", *listen, "client", cfg.Version, "bundle", cfg.BundleVer, "cdn", *cdn, "gameData", verifiedGameData.ArchivePath, "gameDataEntries", verifiedGameData.EntryCount, "accountSeed", *accountSeed)
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
func patchClient(args []string) error {
|
||||
fs := flag.NewFlagSet("patch-client", flag.ContinueOnError)
|
||||
gameDir := fs.String("game-dir", "", "BrownDust II game directory (required)")
|
||||
serverURL := fs.String("url", "http://127.0.0.1:8080/game/", "exactly 27-byte replacement LIVE_URL")
|
||||
verify := fs.Bool("verify", false, "inspect the embedded LIVE_URL without writing")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *gameDir == "" {
|
||||
return errors.New("patch-client requires --game-dir")
|
||||
}
|
||||
if *verify {
|
||||
result, err := introdb.VerifyClient(*gameDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("resources.assets: %s\nTextAsset pathID: %d\nLIVE_URL: %s\n", result.AssetsPath, result.ObjectPath, result.URL)
|
||||
return nil
|
||||
}
|
||||
result, err := introdb.PatchClient(*gameDir, *serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
disabled, err := disableLegacyPlugin(*gameDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err := introdb.VerifyClient(*gameDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("post-patch verification: %w", err)
|
||||
}
|
||||
fmt.Printf("patched: %s\nbackup: %s\nLIVE_URL: %s\n", result.AssetsPath, result.BackupPath, verified.URL)
|
||||
if disabled != "" {
|
||||
fmt.Printf("legacy plugin disabled: %s\n", disabled)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func disableLegacyPlugin(gameDir string) (string, error) {
|
||||
source := filepath.Join(gameDir, "BepInEx", "plugins", "PluginLocalRes.dll")
|
||||
destination := filepath.Join(gameDir, "BepInEx", "disabled", "PluginLocalRes.dll")
|
||||
if _, err := os.Stat(source); errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
} else if err != nil {
|
||||
return "", fmt.Errorf("inspect legacy local-resource plugin: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(destination); err == nil {
|
||||
return "", fmt.Errorf("legacy plugin exists at both active and disabled paths")
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(source, destination); err != nil {
|
||||
return "", fmt.Errorf("disable legacy local-resource plugin: %w", err)
|
||||
}
|
||||
return destination, nil
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, `bd2server - BrownDust II 2.34.13 local development server
|
||||
|
||||
Usage:
|
||||
bd2server serve --cdn DIR --game-data DIR --game-data-version VERSION [options]
|
||||
bd2server patch-client --game-dir DIR [options]
|
||||
bd2server state check [options]
|
||||
bd2server state migrate-v1-v2 [options]
|
||||
bd2server state repair [options]
|
||||
|
||||
The server binds to loopback by default and is intended for local research.`)
|
||||
}
|
||||
Reference in New Issue
Block a user