Skip to content

Commit 8bb5057

Browse files
committed
feat: persist bootstrap characters across sessions
1 parent 42d76c4 commit 8bb5057

4 files changed

Lines changed: 361 additions & 5 deletions

File tree

internal/accountstore/store.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package accountstore
2+
3+
import (
4+
"encoding/hex"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
12+
"github.com/MikelCalvo/go-metin2-server/internal/loginticket"
13+
)
14+
15+
var (
16+
ErrStoreDirRequired = errors.New("account store dir is required")
17+
ErrLoginRequired = errors.New("account login is required")
18+
ErrAccountNotFound = errors.New("account not found")
19+
)
20+
21+
type Account struct {
22+
Login string `json:"login"`
23+
Characters []loginticket.Character `json:"characters"`
24+
}
25+
26+
type Store interface {
27+
Load(login string) (Account, error)
28+
Save(account Account) error
29+
}
30+
31+
type FileStore struct {
32+
dir string
33+
}
34+
35+
func NewFileStore(dir string) *FileStore {
36+
return &FileStore{dir: dir}
37+
}
38+
39+
func (s *FileStore) Load(login string) (Account, error) {
40+
if s.dir == "" {
41+
return Account{}, ErrStoreDirRequired
42+
}
43+
if login == "" {
44+
return Account{}, ErrLoginRequired
45+
}
46+
47+
raw, err := os.ReadFile(s.accountPath(login))
48+
if err != nil {
49+
if errors.Is(err, os.ErrNotExist) {
50+
return Account{}, ErrAccountNotFound
51+
}
52+
return Account{}, fmt.Errorf("read account: %w", err)
53+
}
54+
55+
var account Account
56+
if err := json.Unmarshal(raw, &account); err != nil {
57+
return Account{}, fmt.Errorf("decode account: %w", err)
58+
}
59+
return account, nil
60+
}
61+
62+
func (s *FileStore) Save(account Account) error {
63+
if s.dir == "" {
64+
return ErrStoreDirRequired
65+
}
66+
if account.Login == "" {
67+
return ErrLoginRequired
68+
}
69+
if err := os.MkdirAll(s.dir, 0o755); err != nil {
70+
return fmt.Errorf("create account store dir: %w", err)
71+
}
72+
73+
temp, err := os.CreateTemp(s.dir, ".account-*.json")
74+
if err != nil {
75+
return fmt.Errorf("create account temp file: %w", err)
76+
}
77+
defer func() {
78+
_ = temp.Close()
79+
_ = os.Remove(temp.Name())
80+
}()
81+
82+
if err := json.NewEncoder(temp).Encode(account); err != nil {
83+
return fmt.Errorf("encode account: %w", err)
84+
}
85+
if err := temp.Close(); err != nil {
86+
return fmt.Errorf("close account temp file: %w", err)
87+
}
88+
if err := os.Rename(temp.Name(), s.accountPath(account.Login)); err != nil {
89+
return fmt.Errorf("commit account file: %w", err)
90+
}
91+
return nil
92+
}
93+
94+
func (s *FileStore) accountPath(login string) string {
95+
normalized := strings.ToLower(login)
96+
filename := hex.EncodeToString([]byte(normalized)) + ".json"
97+
return filepath.Join(s.dir, filename)
98+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package accountstore
2+
3+
import (
4+
"errors"
5+
"reflect"
6+
"testing"
7+
8+
"github.com/MikelCalvo/go-metin2-server/internal/loginticket"
9+
)
10+
11+
func TestFileStoreSaveThenLoadRoundTrip(t *testing.T) {
12+
store := NewFileStore(t.TempDir())
13+
want := Account{
14+
Login: "mkmk",
15+
Characters: []loginticket.Character{{
16+
ID: 1,
17+
VID: 0x01020304,
18+
Name: "MkmkWar",
19+
Job: 0,
20+
RaceNum: 0,
21+
Level: 15,
22+
ST: 6,
23+
HT: 5,
24+
DX: 4,
25+
IQ: 3,
26+
MainPart: 101,
27+
HairPart: 201,
28+
X: 1000,
29+
Y: 2000,
30+
Empire: 2,
31+
SkillGroup: 1,
32+
}},
33+
}
34+
35+
if err := store.Save(want); err != nil {
36+
t.Fatalf("save account: %v", err)
37+
}
38+
got, err := store.Load("mkmk")
39+
if err != nil {
40+
t.Fatalf("load account: %v", err)
41+
}
42+
if !reflect.DeepEqual(got, want) {
43+
t.Fatalf("unexpected account:\n got: %#v\nwant: %#v", got, want)
44+
}
45+
}
46+
47+
func TestFileStoreLoadReturnsNotFoundForUnknownLogin(t *testing.T) {
48+
store := NewFileStore(t.TempDir())
49+
_, err := store.Load("ghost")
50+
if !errors.Is(err, ErrAccountNotFound) {
51+
t.Fatalf("expected ErrAccountNotFound, got %v", err)
52+
}
53+
}

internal/minimal/factory.go

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strconv"
1212
"strings"
1313

14+
"github.com/MikelCalvo/go-metin2-server/internal/accountstore"
1415
authflow "github.com/MikelCalvo/go-metin2-server/internal/auth"
1516
"github.com/MikelCalvo/go-metin2-server/internal/authboot"
1617
"github.com/MikelCalvo/go-metin2-server/internal/boot"
@@ -39,13 +40,24 @@ var (
3940
type loginKeyGenerator func() (uint32, error)
4041

4142
func NewAuthSessionFactory() service.SessionFactory {
42-
return newAuthSessionFactory(loginticket.NewFileStore(defaultTicketStoreDir()), randomLoginKey)
43+
return newAuthSessionFactoryWithAccountStore(
44+
loginticket.NewFileStore(defaultTicketStoreDir()),
45+
accountstore.NewFileStore(defaultAccountStoreDir()),
46+
randomLoginKey,
47+
)
4348
}
4449

4550
func newAuthSessionFactory(store loginticket.Store, generateLoginKey loginKeyGenerator) service.SessionFactory {
51+
return newAuthSessionFactoryWithAccountStore(store, accountstore.NewFileStore(defaultAccountStoreDir()), generateLoginKey)
52+
}
53+
54+
func newAuthSessionFactoryWithAccountStore(store loginticket.Store, accounts accountstore.Store, generateLoginKey loginKeyGenerator) service.SessionFactory {
4655
if store == nil {
4756
store = loginticket.NewFileStore(defaultTicketStoreDir())
4857
}
58+
if accounts == nil {
59+
accounts = accountstore.NewFileStore(defaultAccountStoreDir())
60+
}
4961
if generateLoginKey == nil {
5062
generateLoginKey = randomLoginKey
5163
}
@@ -62,7 +74,11 @@ func newAuthSessionFactory(store loginticket.Store, generateLoginKey loginKeyGen
6274
return authflow.Result{Accepted: false, FailureStatus: "WRONGPWD"}
6375
}
6476

65-
loginKey, ok := issueLoginTicket(store, packet.Login, generateLoginKey)
77+
characters, ok := loadOrCreateAccountCharacters(accounts, packet.Login)
78+
if !ok {
79+
return authflow.Result{Accepted: false, FailureStatus: "FAILED"}
80+
}
81+
loginKey, ok := issueLoginTicket(store, packet.Login, characters, generateLoginKey)
6682
if !ok {
6783
return authflow.Result{Accepted: false, FailureStatus: "FAILED"}
6884
}
@@ -75,10 +91,18 @@ func newAuthSessionFactory(store loginticket.Store, generateLoginKey loginKeyGen
7591
}
7692

7793
func NewGameSessionFactory(cfg config.Service) (service.SessionFactory, error) {
78-
return newGameSessionFactory(cfg, loginticket.NewFileStore(defaultTicketStoreDir()))
94+
return newGameSessionFactoryWithAccountStore(
95+
cfg,
96+
loginticket.NewFileStore(defaultTicketStoreDir()),
97+
accountstore.NewFileStore(defaultAccountStoreDir()),
98+
)
7999
}
80100

81101
func newGameSessionFactory(cfg config.Service, store loginticket.Store) (service.SessionFactory, error) {
102+
return newGameSessionFactoryWithAccountStore(cfg, store, accountstore.NewFileStore(defaultAccountStoreDir()))
103+
}
104+
105+
func newGameSessionFactoryWithAccountStore(cfg config.Service, store loginticket.Store, accounts accountstore.Store) (service.SessionFactory, error) {
82106
advertisedPort, err := parsePort(cfg.LegacyAddr)
83107
if err != nil {
84108
return nil, err
@@ -92,6 +116,9 @@ func newGameSessionFactory(cfg config.Service, store loginticket.Store) (service
92116
if store == nil {
93117
store = loginticket.NewFileStore(defaultTicketStoreDir())
94118
}
119+
if accounts == nil {
120+
accounts = accountstore.NewFileStore(defaultAccountStoreDir())
121+
}
95122

96123
return func() service.SessionFlow {
97124
var sessionTicket loginticket.Ticket
@@ -127,6 +154,9 @@ func newGameSessionFactory(cfg config.Service, store loginticket.Store) (service
127154
if !ok {
128155
return worldentry.CreateResult{Accepted: false, FailureType: failureType}
129156
}
157+
if !saveAccountCharacters(accounts, sessionTicket.Login, sessionTicket.Characters) {
158+
return worldentry.CreateResult{Accepted: false, FailureType: 0}
159+
}
130160
return worldentry.CreateResult{
131161
Accepted: true,
132162
Player: ticketPlayerCreateSuccessPacket(created, packet.Index, advertisedAddr, advertisedPort),
@@ -190,7 +220,10 @@ func defaultKeyComplete() control.KeyCompletePacket {
190220
}
191221
}
192222

193-
func issueLoginTicket(store loginticket.Store, login string, generateLoginKey loginKeyGenerator) (uint32, bool) {
223+
func issueLoginTicket(store loginticket.Store, login string, characters []loginticket.Character, generateLoginKey loginKeyGenerator) (uint32, bool) {
224+
if len(characters) == 0 {
225+
characters = stubCharacters()
226+
}
194227
for range 8 {
195228
loginKey, err := generateLoginKey()
196229
if err != nil || loginKey == 0 {
@@ -200,7 +233,7 @@ func issueLoginTicket(store loginticket.Store, login string, generateLoginKey lo
200233
err = store.Issue(loginticket.Ticket{
201234
Login: login,
202235
LoginKey: loginKey,
203-
Characters: stubCharacters(),
236+
Characters: cloneCharacters(characters),
204237
})
205238
if err == nil {
206239
return loginKey, true
@@ -214,6 +247,40 @@ func issueLoginTicket(store loginticket.Store, login string, generateLoginKey lo
214247
return 0, false
215248
}
216249

250+
func loadOrCreateAccountCharacters(store accountstore.Store, login string) ([]loginticket.Character, bool) {
251+
if store == nil {
252+
return cloneCharacters(stubCharacters()), true
253+
}
254+
account, err := store.Load(login)
255+
if err == nil {
256+
return cloneCharacters(account.Characters), true
257+
}
258+
if !errors.Is(err, accountstore.ErrAccountNotFound) {
259+
return nil, false
260+
}
261+
characters := cloneCharacters(stubCharacters())
262+
if err := store.Save(accountstore.Account{Login: login, Characters: characters}); err != nil {
263+
return nil, false
264+
}
265+
return cloneCharacters(characters), true
266+
}
267+
268+
func saveAccountCharacters(store accountstore.Store, login string, characters []loginticket.Character) bool {
269+
if store == nil {
270+
return true
271+
}
272+
return store.Save(accountstore.Account{Login: login, Characters: cloneCharacters(characters)}) == nil
273+
}
274+
275+
func cloneCharacters(characters []loginticket.Character) []loginticket.Character {
276+
if len(characters) == 0 {
277+
return nil
278+
}
279+
cloned := make([]loginticket.Character, len(characters))
280+
copy(cloned, characters)
281+
return cloned
282+
}
283+
217284
func randomLoginKey() (uint32, error) {
218285
var raw [4]byte
219286
for range 8 {
@@ -549,6 +616,10 @@ func defaultTicketStoreDir() string {
549616
return filepath.Join(os.TempDir(), "go-metin2-server-login-tickets")
550617
}
551618

619+
func defaultAccountStoreDir() string {
620+
return filepath.Join(os.TempDir(), "go-metin2-server-accounts")
621+
}
622+
552623
func sequentialBytes32(start byte) [32]byte {
553624
var out [32]byte
554625
for i := range out {

0 commit comments

Comments
 (0)