moved 'core' to 'internal'

This commit is contained in:
2023-08-23 18:16:24 -05:00
parent 74b68863b1
commit 458e907c99
28 changed files with 30 additions and 30 deletions

70
internal/db/account.go Normal file
View File

@@ -0,0 +1,70 @@
package db
import (
"fmt"
"log"
"golang.org/x/crypto/bcrypt"
"github.com/CPunch/gopenfusion/internal/protocol"
"github.com/georgysavva/scany/v2/sqlscan"
)
type Account struct {
AccountID int `db:"accountid"`
Login string `db:"login"`
Password string `db:"password"`
Selected int `db:"selected"`
AccountLevel int `db:"accountlevel"`
Created int `db:"created"`
LastLogin int `db:"lastlogin"`
BannedUntil int `db:"banneduntil"`
BannedSince int `db:"bannedsince"`
BanReason string `db:"banreason"`
}
func (db *DBHandler) NewAccount(Login, Password string) (*Account, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(Password), 12)
if err != nil {
return nil, err
}
row, err := db.Query("INSERT INTO Accounts (Login, Password, AccountLevel) VALUES($1, $2, $3) RETURNING *", Login, hash, protocol.CN_ACCOUNT_LEVEL__USER)
if err != nil {
return nil, err
}
var account Account
row.Next()
if err := sqlscan.ScanRow(&account, row); err != nil {
return nil, err
}
return &account, nil
}
var (
LoginErrorInvalidID = fmt.Errorf("Invalid Login ID!")
LoginErrorInvalidPassword = fmt.Errorf("Invalid ID && Password combo!")
)
func (db *DBHandler) TryLogin(Login, Password string) (*Account, error) {
row, err := db.Query("SELECT * FROM Accounts WHERE Login=$1", Login)
if err != nil {
return nil, err
}
var account Account
row.Next()
if err := sqlscan.ScanRow(&account, row); err != nil {
log.Printf("Error scanning row: %v", err)
return nil, LoginErrorInvalidID
}
if bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(Password)) != nil {
return nil, LoginErrorInvalidPassword
}
// else, login was a success
return &account, nil
}

62
internal/db/inventory.go Normal file
View File

@@ -0,0 +1,62 @@
package db
import (
"database/sql"
"github.com/CPunch/gopenfusion/internal/protocol"
)
type Inventory struct {
PlayerID int `db:"playerid"`
Slot int `db:"slot"`
ID int `db:"id"`
Type int `db:"type"`
Opt int `db:"opt"`
TimeLimit int `db:"timelimit"`
}
// start && end are both inclusive
func (db *DBHandler) GetPlayerInventorySlots(PlayerID int, start int, end int) ([]protocol.SItemBase, error) {
rows, err := db.Query("SELECT Slot, Type, ID, Opt, TimeLimit FROM Inventory WHERE Slot BETWEEN $1 AND $2 AND PlayerID = $3", start, end, PlayerID)
if err != nil {
return nil, err
}
items := make([]protocol.SItemBase, end-start)
for rows.Next() {
var slot int
item := protocol.SItemBase{}
if err := rows.Scan(
&slot, &item.IType, &item.IID, &item.IOpt, &item.ITimeLimit); err != nil {
return nil, err
}
items[slot-start] = item
}
return items, nil
}
// start is inclusive
func (db *DBHandler) SetPlayerInventorySlots(PlayerID int, start int, items []protocol.SItemBase) error {
return db.Transaction(func(tx *sql.Tx) error {
// delete inventory slots
_, err := db.Exec("DELETE FROM Inventory WHERE Slot BETWEEN $1 AND $2 AND PlayerID = $3", start, start+len(items)-1, PlayerID)
if err != nil {
return err
}
// insert inventory
for i, item := range items {
if item.IID != 0 {
_, err := db.Exec("INSERT INTO Inventory (PlayerID, Slot, ID, Type, Opt, TimeLimit) VALUES ($1, $2, $3, $4, $5, $6)", PlayerID, start+i, item.IID, item.IType, item.IOpt, item.ITimeLimit)
if err != nil {
return err
}
}
}
return nil
})
}

View File

@@ -0,0 +1,166 @@
-- this file has been lifted from https://github.com/OpenFusionProject/OpenFusion/bytea/master/sql/tables.sql
-- all credit to original contributors!
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE IF NOT EXISTS Accounts (
AccountID SERIAL NOT NULL,
Login CITEXT NOT NULL UNIQUE,
Password TEXT NOT NULL,
Selected INTEGER DEFAULT 1 NOT NULL,
AccountLevel INTEGER NOT NULL,
Created INTEGER DEFAULT (extract(EPOCH FROM current_time)) NOT NULL,
LastLogin INTEGER DEFAULT (extract(EPOCH FROM current_time)) NOT NULL,
BannedUntil INTEGER DEFAULT 0 NOT NULL,
BannedSince INTEGER DEFAULT 0 NOT NULL,
BanReason TEXT DEFAULT '' NOT NULL,
PRIMARY KEY(AccountID)
);
CREATE TABLE IF NOT EXISTS Players (
PlayerID SERIAL NOT NULL,
AccountID INTEGER NOT NULL,
FirstName CITEXT NOT NULL,
LastName CITEXT NOT NULL,
NameCheck INTEGER NOT NULL,
Slot INTEGER NOT NULL,
Created INTEGER DEFAULT (extract(EPOCH FROM current_time)) NOT NULL,
LastLogin INTEGER DEFAULT (extract(EPOCH FROM current_time)) NOT NULL,
Level INTEGER DEFAULT 1 NOT NULL,
Nano1 INTEGER DEFAULT 0 NOT NULL,
Nano2 INTEGER DEFAULT 0 NOT NULL,
Nano3 INTEGER DEFAULT 0 NOT NULL,
AppearanceFlag INTEGER DEFAULT 0 NOT NULL,
TutorialFlag INTEGER DEFAULT 0 NOT NULL,
PayZoneFlag INTEGER DEFAULT 0 NOT NULL,
XCoordinate INTEGER NOT NULL,
YCoordinate INTEGER NOT NULL,
ZCoordinate INTEGER NOT NULL,
Angle INTEGER NOT NULL,
HP INTEGER NOT NULL,
FusionMatter INTEGER DEFAULT 0 NOT NULL,
Taros INTEGER DEFAULT 0 NOT NULL,
BatteryW INTEGER DEFAULT 0 NOT NULL,
BatteryN INTEGER DEFAULT 0 NOT NULL,
Mentor INTEGER DEFAULT 5 NOT NULL,
CurrentMissionID INTEGER DEFAULT 0 NOT NULL,
WarpLocationFlag INTEGER DEFAULT 0 NOT NULL,
SkywayLocationFlag bytea NOT NULL,
FirstUseFlag bytea NOT NULL,
Quests bytea NOT NULL,
PRIMARY KEY(PlayerID),
FOREIGN KEY(AccountID) REFERENCES Accounts(AccountID) ON DELETE CASCADE,
UNIQUE (AccountID, Slot),
UNIQUE (FirstName, LastName)
);
CREATE TABLE IF NOT EXISTS Appearances (
PlayerID INTEGER UNIQUE NOT NULL,
Body INTEGER DEFAULT 0 NOT NULL,
EyeColor INTEGER DEFAULT 1 NOT NULL,
FaceStyle INTEGER DEFAULT 1 NOT NULL,
Gender INTEGER DEFAULT 1 NOT NULL,
HairColor INTEGER DEFAULT 1 NOT NULL,
HairStyle INTEGER DEFAULT 1 NOT NULL,
Height INTEGER DEFAULT 0 NOT NULL,
SkinColor INTEGER DEFAULT 1 NOT NULL,
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS Inventory (
PlayerID INTEGER NOT NULL,
Slot INTEGER NOT NULL,
ID INTEGER NOT NULL,
Type INTEGER NOT NULL,
Opt INTEGER NOT NULL,
TimeLimit INTEGER DEFAULT 0 NOT NULL,
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
UNIQUE (PlayerID, Slot)
);
CREATE TABLE IF NOT EXISTS QuestItems (
PlayerID INTEGER NOT NULL,
Slot INTEGER NOT NULL,
ID INTEGER NOT NULL,
Opt INTEGER NOT NULL,
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
UNIQUE (PlayerID, Slot)
);
CREATE TABLE IF NOT EXISTS Nanos (
PlayerID INTEGER NOT NULL,
ID INTEGER NOT NULL,
Skill INTEGER NOT NULL,
Stamina INTEGER DEFAULT 150 NOT NULL,
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
UNIQUE (PlayerID, ID)
);
CREATE TABLE IF NOT EXISTS RunningQuests (
PlayerID INTEGER NOT NULL,
TaskID INTEGER NOT NULL,
RemainingNPCCount1 INTEGER NOT NULL,
RemainingNPCCount2 INTEGER NOT NULL,
RemainingNPCCount3 INTEGER NOT NULL,
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS Buddyships (
PlayerAID INTEGER NOT NULL,
PlayerBID INTEGER NOT NULL,
FOREIGN KEY(PlayerAID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
FOREIGN KEY(PlayerBID) REFERENCES Players(PlayerID) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS Blocks (
PlayerID INTEGER NOT NULL,
BlockedPlayerID INTEGER NOT NULL,
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
FOREIGN KEY(BlockedPlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS EmailData (
PlayerID INTEGER NOT NULL,
MsgIndex INTEGER NOT NULL,
ReadFlag INTEGER NOT NULL,
ItemFlag INTEGER NOT NULL,
SenderID INTEGER NOT NULL,
SenderFirstName CITEXT NOT NULL,
SenderLastName CITEXT NOT NULL,
SubjectLine TEXT NOT NULL,
MsgBody TEXT NOT NULL,
Taros INTEGER NOT NULL,
SendTime INTEGER NOT NULL,
DeleteTime INTEGER NOT NULL,
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
UNIQUE(PlayerID, MsgIndex)
);
CREATE TABLE IF NOT EXISTS EmailItems (
PlayerID INTEGER NOT NULL,
MsgIndex INTEGER NOT NULL,
Slot INTEGER NOT NULL,
ID INTEGER NOT NULL,
Type INTEGER NOT NULL,
Opt INTEGER NOT NULL,
TimeLimit INTEGER NOT NULL,
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
UNIQUE (PlayerID, MsgIndex, Slot)
);
CREATE TABLE IF NOT EXISTS RaceResults(
EPID INTEGER NOT NULL,
PlayerID INTEGER NOT NULL,
Score INTEGER NOT NULL,
RingCount INTEGER NOT NULL,
Time INTEGER NOT NULL,
Timestamp INTEGER NOT NULL,
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS RedeemedCodes(
PlayerID INTEGER NOT NULL,
Code TEXT NOT NULL,
FOREIGN KEY(PlayerID) REFERENCES Players(PlayerID) ON DELETE CASCADE,
UNIQUE (PlayerID, Code)
)

199
internal/db/players.go Normal file
View File

@@ -0,0 +1,199 @@
package db
import (
"database/sql"
"github.com/CPunch/gopenfusion/config"
"github.com/CPunch/gopenfusion/internal/entity"
"github.com/CPunch/gopenfusion/internal/protocol"
"github.com/blockloop/scan"
)
// returns PlayerID, error
func (db *DBHandler) NewPlayer(AccountID int, FirstName, LastName string, slot int) (int, error) {
nameCheck := 1 // for now, we approve all names
QuestFlag := make([]byte, 128)
SkywayLocationFlag := make([]byte, 16)
FirstUseFlag := make([]byte, 16)
var PlayerID int
if err := db.Transaction(func(tx *sql.Tx) error {
// create player
rows, err := tx.Query(
"INSERT INTO Players (AccountID, Slot, FirstName, LastName, XCoordinate, YCoordinate, ZCoordinate, Angle, HP, NameCheck, Quests, SkywayLocationFlag, FirstUseFlag) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING PlayerID",
AccountID, slot, FirstName, LastName, config.SPAWN_X, config.SPAWN_Y, config.SPAWN_Z, 0, config.GetMaxHP(1), nameCheck, QuestFlag, SkywayLocationFlag, FirstUseFlag)
if err != nil {
return err
}
if err := scan.Row(&PlayerID, rows); err != nil {
return err
}
// create appearance
if _, err := tx.Exec("INSERT INTO Appearances (PlayerID) VALUES ($1)", PlayerID); err != nil {
return err
}
return nil
}); err != nil {
return -1, nil
}
return PlayerID, nil
}
// TODO: should this operate on the raw packet? should we do validation here or prior?
func (db *DBHandler) FinishPlayer(character *protocol.SP_CL2LS_REQ_CHAR_CREATE, AccountId int) error {
return db.Transaction(func(tx *sql.Tx) error {
// update AppearanceFlag
_, err := tx.Exec("UPDATE Players SET AppearanceFlag = 1 WHERE PlayerID = $1 AND AccountID = $2 AND AppearanceFlag = 0", character.PCStyle.IPC_UID, AccountId)
if err != nil {
return err
}
// update Appearance
_, err = tx.Exec("UPDATE Appearances SET Body = $1, EyeColor = $2, FaceStyle = $3, Gender = $4, HairColor = $5, HairStyle = $6, Height = $7, SkinColor = $8 WHERE PlayerID = $9",
character.PCStyle.IBody,
character.PCStyle.IEyeColor,
character.PCStyle.IFaceStyle,
character.PCStyle.IGender,
character.PCStyle.IHairColor,
character.PCStyle.IHairStyle,
character.PCStyle.IHeight,
character.PCStyle.ISkinColor,
character.PCStyle.IPC_UID)
if err != nil {
return err
}
// update Inventory
items := [3]int16{character.SOn_Item.IEquipUBID, character.SOn_Item.IEquipLBID, character.SOn_Item.IEquipFootID}
for i := 0; i < len(items); i++ {
_, err = tx.Exec("INSERT INTO Inventory (PlayerID, Slot, ID, Type, Opt) VALUES ($1, $2, $3, $4, 1)", character.PCStyle.IPC_UID, i+1, items[i], i+1)
if err != nil {
return err
}
}
return nil
})
}
func (db *DBHandler) FinishTutorial(PlayerID, AccountID int) error {
_, err := db.Exec("UPDATE Players SET TutorialFlag = 1 WHERE PlayerID = $1 AND AccountID = $2 AND TutorialFlag = 0", PlayerID, AccountID)
if err != nil {
return err
}
// TODO: reference openfusion's finishTutorial for their academy specific patches
return nil
}
// returns the deleted Slot number
func (db *DBHandler) DeletePlayer(PlayerID, AccountID int) (int, error) {
row, err := db.Query("DELETE FROM Players WHERE AccountID = $1 AND PlayerID = $2 RETURNING Slot")
if err != nil {
return -1, err
}
var slot int
if err := row.Scan(&slot); err != nil {
return -1, err
}
return slot, nil
}
const (
QUERY_PLAYERS = `SELECT
p.PlayerID, p.AccountID, p.Slot, p.FirstName, p.LastName,
p.Level, p.Nano1, p.Nano2, p.Nano3,
p.AppearanceFlag, p.TutorialFlag, p.PayZoneFlag,
p.XCoordinate, p.YCoordinate, p.ZCoordinate, p.NameCheck,
p.Angle, p.HP, p.FusionMatter, p.Taros, p.Quests,
p.BatteryW, p.BatteryN, p.Mentor, p.WarpLocationFlag,
p.SkywayLocationFlag, p.CurrentMissionID, p.FirstUseFlag,
a.Body, a.EyeColor, a.FaceStyle, a.Gender, a.HairColor, a.HairStyle,
a.Height, a.SkinColor, acc.AccountLevel
FROM Players as p
INNER JOIN Appearances as a ON p.PlayerID = a.PlayerID
INNER JOIN Accounts as acc ON p.AccountID = acc.AccountID `
)
func (db *DBHandler) readPlayer(rows *sql.Rows) (*entity.Player, error) {
plr := entity.Player{ActiveNanoSlotNum: 0}
if err := rows.Scan(
&plr.PlayerID, &plr.AccountID, &plr.Slot, &plr.PCStyle.SzFirstName, &plr.PCStyle.SzLastName,
&plr.Level, &plr.EquippedNanos[0], &plr.EquippedNanos[1], &plr.EquippedNanos[2],
&plr.PCStyle2.IAppearanceFlag, &plr.PCStyle2.ITutorialFlag, &plr.PCStyle2.IPayzoneFlag,
&plr.X, &plr.Y, &plr.Z, &plr.PCStyle.INameCheck,
&plr.Angle, &plr.HP, &plr.FusionMatter, &plr.Taros, &plr.Quests,
&plr.BatteryW, &plr.BatteryN, &plr.Mentor, &plr.WarpLocationFlag,
&plr.SkywayLocationFlag, &plr.CurrentMissionID, &plr.FirstUseFlag,
&plr.PCStyle.IBody, &plr.PCStyle.IEyeColor, &plr.PCStyle.IFaceStyle, &plr.PCStyle.IGender, &plr.PCStyle.IHairColor, &plr.PCStyle.IHairStyle,
&plr.PCStyle.IHeight, &plr.PCStyle.ISkinColor, &plr.AccountLevel); err != nil {
return nil, err
}
plr.PCStyle.IPC_UID = int64(plr.PlayerID)
inven, err := db.GetPlayerInventorySlots(plr.PlayerID, 0, config.AEQUIP_COUNT+config.AINVEN_COUNT+config.ABANK_COUNT)
if err != nil {
return nil, err
}
// populate player inven
for i, item := range inven {
if item.IID == 0 { // skip empty slots
continue
}
switch {
case i < config.AEQUIP_COUNT:
plr.Equip[i] = item
case i < config.AEQUIP_COUNT+config.AINVEN_COUNT:
plr.Inven[i-config.AEQUIP_COUNT] = item
default:
plr.Bank[i-config.AEQUIP_COUNT-config.AINVEN_COUNT] = item
}
}
return &plr, nil
}
func (db *DBHandler) GetPlayer(PlayerID int) (*entity.Player, error) {
rows, err := db.Query(QUERY_PLAYERS+"WHERE p.PlayerID = $1", PlayerID)
if err != nil {
return nil, err
}
var plr *entity.Player
for rows.Next() {
plr, err = db.readPlayer(rows)
if err != nil {
return nil, err
}
}
return plr, nil
}
func (db *DBHandler) GetPlayers(AccountID int) ([]entity.Player, error) {
rows, err := db.Query(QUERY_PLAYERS+"WHERE p.AccountID = $1", AccountID)
if err != nil {
return nil, err
}
var plrs []entity.Player
for rows.Next() {
plr, err := db.readPlayer(rows)
if err != nil {
return nil, err
}
plrs = append(plrs, *plr)
}
return plrs, nil
}

73
internal/db/schema.go Normal file
View File

@@ -0,0 +1,73 @@
package db
/*
This database has been based off of openfusion's. Databases should be completely interchangable between
openfusion and gopenfusion.
*/
import (
"database/sql"
_ "embed"
"fmt"
"github.com/CPunch/gopenfusion/config"
_ "github.com/lib/pq"
)
type DBHandler struct {
db *sql.DB
}
//go:embed migrations/new.sql
var createDBQuery string
func OpenPostgresDB(dbAddr string) (*DBHandler, error) {
fmt := fmt.Sprintf("postgresql://%s:%s@%s/%s?sslmode=disable", config.GetDBUser(), config.GetDBPass(), dbAddr, config.GetDBName())
db, err := sql.Open("postgres", fmt)
if err != nil {
return nil, err
}
return &DBHandler{db}, nil
}
func (db *DBHandler) Query(query string, args ...any) (*sql.Rows, error) {
return db.db.Query(query, args...)
}
func (db *DBHandler) Exec(query string, args ...any) (sql.Result, error) {
return db.db.Exec(query, args...)
}
func (db *DBHandler) Close() error {
return db.db.Close()
}
func (db *DBHandler) Setup() error {
// create db tables
_, err := db.db.Exec(createDBQuery)
return err
}
// calls transaction, if transaction returns a non-nil error the transaction is rolled back. otherwise the transaction is committed
func (db *DBHandler) Transaction(transaction func(*sql.Tx) error) (err error) {
tx, err := db.db.Begin()
if err != nil {
return
}
defer func() {
if p := recover(); p != nil {
// we panic'd ??? rollback and rethrow
tx.Rollback()
panic(p)
} else if err != nil {
tx.Rollback()
} else {
err = tx.Commit()
}
}()
err = transaction(tx)
return
}

86
internal/entity/chunk.go Normal file
View File

@@ -0,0 +1,86 @@
package entity
import (
"log"
"sync"
)
type Chunk struct {
Position ChunkPosition
Entities map[Entity]struct{}
lock sync.Mutex
}
func NewChunk(position ChunkPosition) *Chunk {
return &Chunk{
Position: position,
Entities: make(map[Entity]struct{}),
}
}
func (c *Chunk) AddEntity(entity Entity) {
c.Entities[entity] = struct{}{}
}
func (c *Chunk) RemoveEntity(entity Entity) {
delete(c.Entities, entity)
}
// send packet to all peers in this chunk and kill each peer if error
func (c *Chunk) SendPacket(typeID uint32, pkt ...interface{}) {
c.SendPacketExclude(nil, typeID, pkt...)
}
func (c *Chunk) SendPacketExclude(exclude Entity, typeID uint32, pkt ...interface{}) {
c.lock.Lock()
defer c.lock.Unlock()
for entity := range c.Entities {
// only send to players, and exclude the player that sent the packet
if entity.GetKind() != ENTITY_KIND_PLAYER || entity == exclude {
continue
}
plr, ok := entity.(*Player)
if !ok {
log.Panic("Chunk.SendPacket: entity kind was player, but is not a *Player")
}
peer := plr.Peer
if err := peer.Send(typeID, pkt...); err != nil {
log.Printf("Error sending packet to peer %p: %v", peer, err)
peer.Kill()
}
}
}
func (c *Chunk) GetAdjacentPositions() []ChunkPosition {
return []ChunkPosition{
{c.Position.X - 1, c.Position.Y - 1},
{c.Position.X - 1, c.Position.Y},
{c.Position.X - 1, c.Position.Y + 1},
{c.Position.X, c.Position.Y - 1},
{c.Position.X, c.Position.Y},
{c.Position.X, c.Position.Y + 1},
{c.Position.X + 1, c.Position.Y - 1},
{c.Position.X + 1, c.Position.Y},
{c.Position.X + 1, c.Position.Y + 1},
}
}
// https://stackoverflow.com/a/45428032 lol
func ChunkSliceDifference(a, b []*Chunk) []*Chunk {
m := make(map[*Chunk]struct{})
for _, item := range b {
m[item] = struct{}{}
}
var diff []*Chunk
for _, item := range a {
if _, ok := m[item]; !ok {
diff = append(diff, item)
}
}
return diff
}

View File

@@ -0,0 +1,15 @@
package entity
import "github.com/CPunch/gopenfusion/config"
type ChunkPosition struct {
X int
Y int
}
func MakeChunkPosition(x, y int) ChunkPosition {
return ChunkPosition{
X: x / (config.VIEW_DISTANCE / 3),
Y: y / (config.VIEW_DISTANCE / 3),
}
}

25
internal/entity/entity.go Normal file
View File

@@ -0,0 +1,25 @@
package entity
import "github.com/CPunch/gopenfusion/internal/protocol"
type EntityKind int
const (
ENTITY_KIND_PLAYER EntityKind = iota
ENTITY_KIND_NPC
)
type Entity interface {
GetKind() EntityKind
GetChunk() ChunkPosition
GetPosition() (x int, y int, z int)
GetAngle() int
SetChunk(chunk ChunkPosition)
SetPosition(x, y, z int)
SetAngle(angle int)
DisappearFromViewOf(peer *protocol.CNPeer)
EnterIntoViewOf(peer *protocol.CNPeer)
}

87
internal/entity/npc.go Normal file
View File

@@ -0,0 +1,87 @@
package entity
import (
"sync/atomic"
"github.com/CPunch/gopenfusion/internal/protocol"
)
type NPC struct {
ID int
X int `json:"iX"`
Y int `json:"iY"`
Z int `json:"iZ"`
Angle int `json:"iAngle"`
NPCType int `json:"iNPCType"`
Chunk ChunkPosition
}
var nextNPCID = &atomic.Int32{}
func NewNPC(X, Y, Z, Angle int, npcType int) *NPC {
return &NPC{
ID: int(nextNPCID.Add(1)),
X: X,
Y: Y,
Z: Z,
Angle: Angle,
NPCType: npcType,
Chunk: MakeChunkPosition(X, Y),
}
}
// ==================== Entity interface ====================
func (npc *NPC) GetKind() EntityKind {
return ENTITY_KIND_NPC
}
func (npc *NPC) GetChunk() ChunkPosition {
return npc.Chunk
}
func (npc *NPC) GetPosition() (x int, y int, z int) {
return npc.X, npc.Y, npc.Z
}
func (npc *NPC) GetAngle() int {
return npc.Angle
}
func (npc *NPC) SetChunk(chunk ChunkPosition) {
npc.Chunk = chunk
}
func (npc *NPC) SetPosition(x, y, z int) {
npc.X = x
npc.Y = y
npc.Z = z
}
func (npc *NPC) SetAngle(angle int) {
npc.Angle = angle
}
func (npc *NPC) DisappearFromViewOf(peer *protocol.CNPeer) {
peer.Send(protocol.P_FE2CL_NPC_EXIT, protocol.SP_FE2CL_NPC_EXIT{
INPC_ID: int32(npc.ID),
})
}
func (npc *NPC) EnterIntoViewOf(peer *protocol.CNPeer) {
peer.Send(protocol.P_FE2CL_NPC_NEW, protocol.SP_FE2CL_NPC_NEW{
NPCAppearanceData: npc.GetAppearanceData(),
})
}
func (npc *NPC) GetAppearanceData() protocol.SNPCAppearanceData {
return protocol.SNPCAppearanceData{
INPC_ID: int32(npc.ID),
INPCType: int32(npc.NPCType),
IHP: 100,
IX: int32(npc.X),
IY: int32(npc.Y),
IZ: int32(npc.Z),
IAngle: int32(npc.Angle),
}
}

127
internal/entity/player.go Normal file
View File

@@ -0,0 +1,127 @@
package entity
import (
"github.com/CPunch/gopenfusion/config"
"github.com/CPunch/gopenfusion/internal/protocol"
)
type Player struct {
Peer *protocol.CNPeer
Chunk ChunkPosition
PlayerID int
AccountID int
AccountLevel int
Slot int
PCStyle protocol.SPCStyle
PCStyle2 protocol.SPCStyle2
EquippedNanos [3]int
Nanos [config.NANO_COUNT]protocol.SNano
Equip [config.AEQUIP_COUNT]protocol.SItemBase
Inven [config.AINVEN_COUNT]protocol.SItemBase
Bank [config.ABANK_COUNT]protocol.SItemBase
SkywayLocationFlag []byte
FirstUseFlag []byte
Quests []byte
HP int
Level int
Taros int
FusionMatter int
Mentor int
X, Y, Z int
Angle int
BatteryN int
BatteryW int
WarpLocationFlag int
ActiveNanoSlotNum int
Fatigue int
CurrentMissionID int
IPCState int8
}
// ==================== Entity interface ====================
func (plr *Player) GetKind() EntityKind {
return ENTITY_KIND_PLAYER
}
func (plr *Player) GetChunk() ChunkPosition {
return plr.Chunk
}
func (plr *Player) GetPosition() (x int, y int, z int) {
return plr.X, plr.Y, plr.Z
}
func (plr *Player) GetAngle() int {
return plr.Angle
}
func (plr *Player) SetChunk(chunk ChunkPosition) {
plr.Chunk = chunk
}
func (plr *Player) SetPosition(x, y, z int) {
plr.X = x
plr.Y = y
plr.Z = z
}
func (plr *Player) SetAngle(angle int) {
plr.Angle = angle
}
func (plr *Player) DisappearFromViewOf(peer *protocol.CNPeer) {
peer.Send(protocol.P_FE2CL_PC_EXIT, protocol.SP_FE2CL_PC_EXIT{
IID: int32(plr.PlayerID),
})
}
func (plr *Player) EnterIntoViewOf(peer *protocol.CNPeer) {
peer.Send(protocol.P_FE2CL_PC_NEW, protocol.SP_FE2CL_PC_NEW{
PCAppearanceData: plr.GetAppearanceData(),
})
}
func (plr *Player) ToPCLoadData2CL() protocol.SPCLoadData2CL {
return protocol.SPCLoadData2CL{
IUserLevel: int16(plr.AccountLevel),
PCStyle: plr.PCStyle,
PCStyle2: plr.PCStyle2,
IMentor: int16(plr.Mentor),
IMentorCount: 1,
IHP: int32(plr.HP),
IBatteryW: int32(plr.BatteryW),
IBatteryN: int32(plr.BatteryN),
ICandy: int32(plr.Taros),
IFusionMatter: int32(plr.FusionMatter),
ISpecialState: 0,
IMapNum: 0,
IX: int32(plr.X),
IY: int32(plr.Y),
IZ: int32(plr.Z),
IAngle: int32(plr.Angle),
AEquip: plr.Equip,
AInven: plr.Inven,
ANanoSlots: [3]int16{int16(plr.EquippedNanos[0]), int16(plr.EquippedNanos[1]), int16(plr.EquippedNanos[2])},
IActiveNanoSlotNum: int16(plr.ActiveNanoSlotNum),
IWarpLocationFlag: int32(plr.WarpLocationFlag),
IBuddyWarpTime: 60,
IFatigue: 50,
}
}
func (plr *Player) GetAppearanceData() protocol.SPCAppearanceData {
return protocol.SPCAppearanceData{
IID: int32(plr.PlayerID),
IHP: int32(plr.HP),
ILv: int16(plr.Level),
IX: int32(plr.X),
IY: int32(plr.Y),
IZ: int32(plr.Z),
IAngle: int32(plr.Angle),
PCStyle: plr.PCStyle,
IPCState: plr.IPCState,
ItemEquip: plr.Equip,
Nano: plr.Nanos[plr.ActiveNanoSlotNum],
}
}

150
internal/protocol/cnpeer.go Normal file
View File

@@ -0,0 +1,150 @@
package protocol
import (
"encoding/binary"
"fmt"
"io"
"log"
"net"
"time"
"github.com/CPunch/gopenfusion/internal/protocol/pool"
)
const (
USE_E = iota
USE_FE
)
// CNPeer is a simple wrapper for net.Conn connections to send/recv packets over the Fusionfall packet protocol.
type CNPeer struct {
conn net.Conn
eRecv chan *Event
SzID string
E_key []byte
FE_key []byte
AccountID int
PlayerID int32
whichKey int
alive bool
}
func GetTime() uint64 {
return uint64(time.Now().UnixMilli())
}
func NewCNPeer(eRecv chan *Event, conn net.Conn) *CNPeer {
return &CNPeer{
conn: conn,
eRecv: eRecv,
SzID: "",
E_key: []byte(DEFAULT_KEY),
FE_key: nil,
AccountID: -1,
whichKey: USE_E,
alive: true,
}
}
func (peer *CNPeer) Send(typeID uint32, data ...interface{}) error {
// grab buffer from pool
buf := pool.Get()
defer pool.Put(buf)
// allocate space for packet size
buf.Write(make([]byte, 4))
// body start
pkt := NewPacket(buf)
// encode type id
if err := pkt.Encode(typeID); err != nil {
return err
}
// encode data
for _, trailer := range data {
if err := pkt.Encode(trailer); err != nil {
return err
}
}
// prepend the packet size
binary.LittleEndian.PutUint32(buf.Bytes()[:4], uint32(buf.Len()-4))
// encrypt body
switch peer.whichKey {
case USE_E:
EncryptData(buf.Bytes()[4:], peer.E_key)
case USE_FE:
EncryptData(buf.Bytes()[4:], peer.FE_key)
}
// send full packet
log.Printf("Sending %#v, sizeof: %d, buffer: %v", data, buf.Len(), buf.Bytes())
if _, err := peer.conn.Write(buf.Bytes()); err != nil {
return fmt.Errorf("failed to write packet body! %v", err)
}
return nil
}
func (peer *CNPeer) SetActiveKey(whichKey int) {
peer.whichKey = whichKey
}
func (peer *CNPeer) Kill() {
log.Printf("Killing peer %p", peer)
if !peer.alive {
return
}
peer.alive = false
peer.conn.Close()
peer.eRecv <- &Event{Type: EVENT_CLIENT_DISCONNECT, Peer: peer}
}
// meant to be invoked as a goroutine
func (peer *CNPeer) Handler() {
defer peer.Kill()
for {
// read packet size, the goroutine spends most of it's time parked here
var sz uint32
if err := binary.Read(peer.conn, binary.LittleEndian, &sz); err != nil {
log.Printf("[FATAL] failed to read packet size! %v\n", err)
return
}
// client should never send a packet size outside of this range
if sz > CN_PACKET_BUFFER_SIZE || sz < 4 {
log.Printf("[FATAL] malicious packet size received! %d", sz)
return
}
// grab buffer && read packet body
if err := func() error {
buf := pool.Get()
if _, err := buf.ReadFrom(io.LimitReader(peer.conn, int64(sz))); err != nil {
return fmt.Errorf("failed to read packet body! %v", err)
}
// decrypt
DecryptData(buf.Bytes(), peer.E_key)
pkt := NewPacket(buf)
// create packet && read pktID
var pktID uint32
if err := pkt.Decode(&pktID); err != nil {
return fmt.Errorf("failed to read packet type! %v", err)
}
// dispatch packet
log.Printf("Got packet ID: %x, with a sizeof: %d\n", pktID, sz)
peer.eRecv <- &Event{Type: EVENT_CLIENT_PACKET, Peer: peer, Pkt: buf, PktID: pktID}
return nil
}(); err != nil {
log.Printf("[FATAL] %v", err)
return
}
}
}

View File

@@ -0,0 +1,71 @@
package protocol
import (
"crypto/rand"
"encoding/binary"
)
const (
DEFAULT_KEY = "m@rQn~W#"
KEY_LENGTH = 8
)
func encrypt_byte_change_A(ERSize int, data []byte) int {
var num, num2, num3 int
for num+ERSize <= len(data) {
num4 := num + num3
num5 := num + (ERSize - 1 - num3)
b := data[num4]
data[num4] = data[num5]
data[num5] = b
num += ERSize
num3++
if num3 > ERSize/2 {
num3 = 0
}
}
num2 = ERSize - (num + ERSize - len(data))
return num + num2
}
func xorData(buff, key []byte, size int) {
for i := 0; i < size; i++ {
buff[i] ^= key[i%KEY_LENGTH]
}
}
func EncryptData(buff, key []byte) {
ERSize := len(buff)%(KEY_LENGTH/2+1)*2 + KEY_LENGTH
xorData(buff, key, len(buff))
encrypt_byte_change_A(ERSize, buff)
}
func DecryptData(buff, key []byte) {
ERSize := len(buff)%(KEY_LENGTH/2+1)*2 + KEY_LENGTH
size := encrypt_byte_change_A(ERSize, buff)
xorData(buff, key, size)
}
func CreateNewKey(uTime uint64, iv1, iv2 uint64) []byte {
num := iv1 + 1
num2 := iv2 + 1
dEKey := uint64(binary.LittleEndian.Uint64([]byte(DEFAULT_KEY)))
key := dEKey * (uTime * num * num2)
buf := make([]byte, 8)
binary.LittleEndian.PutUint64(buf, uint64(key))
return buf
}
func GenSerialKey() (int64, error) {
tmp := [8]byte{}
if _, err := rand.Read(tmp[:]); err != nil {
return 0, err
}
// convert to uint64 && return
return int64(binary.LittleEndian.Uint64(tmp[:])), nil
}

View File

@@ -0,0 +1,15 @@
package protocol
import "bytes"
const (
EVENT_CLIENT_DISCONNECT = iota
EVENT_CLIENT_PACKET
)
type Event struct {
Type int
Peer *CNPeer
Pkt *bytes.Buffer
PktID uint32
}

158
internal/protocol/packet.go Normal file
View File

@@ -0,0 +1,158 @@
package protocol
import (
"encoding/binary"
"fmt"
"io"
"reflect"
"strconv"
"unicode/utf16"
)
/*
this file handles serializing (and deserializing) structs to alignment-strict c structures generated via `tools/genstructs.py`.
see script for details on usage!
*/
type Packet struct {
readWriter io.ReadWriter
}
func NewPacket(readWriter io.ReadWriter) Packet {
return Packet{
readWriter: readWriter,
}
}
func (pkt Packet) encodeStructField(field reflect.StructField, value reflect.Value) error {
// log.Printf("Encoding '%s'", field.Name)
switch field.Type.Kind() {
case reflect.String: // all strings in fusionfall packets are encoded as utf16, we'll need to encode it
sz, err := strconv.Atoi(field.Tag.Get("size"))
if err != nil {
return fmt.Errorf("failed to grab string 'size' tag")
}
buf16 := utf16.Encode([]rune(value.String()))
// len(buf16) needs to be the same size as sz
if len(buf16) > sz {
// truncate
buf16 = buf16[:sz]
} else {
// grow
// TODO: probably a better way to do this?
for len(buf16) < sz {
buf16 = append(buf16, 0)
}
}
// write
if err := binary.Write(pkt.readWriter, binary.LittleEndian, buf16); err != nil {
return err
}
default:
if err := pkt.Encode(value.Interface()); err != nil {
return err
}
}
// write padding bytes
pad, err := strconv.Atoi(field.Tag.Get("pad"))
if err == nil {
for i := 0; i < pad; i++ {
if _, err := pkt.readWriter.Write([]byte{0}); err != nil {
return err
}
}
}
return nil
}
func (pkt Packet) Encode(data interface{}) error {
rv := reflect.Indirect(reflect.ValueOf(data))
switch rv.Kind() {
case reflect.Struct:
// walk through each struct fields
sz := rv.NumField()
for i := 0; i < sz; i++ {
if err := pkt.encodeStructField(rv.Type().Field(i), rv.Field(i)); err != nil {
return err
}
}
default:
// we pass everything else to go's binary package
if err := binary.Write(pkt.readWriter, binary.LittleEndian, data); err != nil {
return err
}
}
return nil
}
func (pkt Packet) decodeStructField(field reflect.StructField, value reflect.Value) error {
// log.Printf("Decoding '%s'", field.Name)
switch field.Type.Kind() {
case reflect.String: // all strings in fusionfall packets are encoded as utf16, we'll need to decode it
sz, err := strconv.Atoi(field.Tag.Get("size"))
if err != nil {
return fmt.Errorf("failed to grab string 'size' tag")
}
buf16 := make([]uint16, sz)
if err := binary.Read(pkt.readWriter, binary.LittleEndian, buf16); err != nil {
return err
}
// find null terminator
var realSize int
for ; realSize < len(buf16); realSize++ {
if buf16[realSize] == 0 {
break
}
}
value.SetString(string(utf16.Decode(buf16[:realSize])))
default:
if err := pkt.Decode(value.Addr().Interface()); err != nil {
return err
}
}
// consume padding bytes
pad, err := strconv.Atoi(field.Tag.Get("pad"))
if err == nil {
for i := 0; i < pad; i++ {
if _, err := pkt.readWriter.Read([]byte{0}); err != nil {
return err
}
}
}
return nil
}
func (pkt Packet) Decode(data interface{}) error {
rv := reflect.Indirect(reflect.ValueOf(data))
switch rv.Kind() {
case reflect.Struct:
// walk through each struct fields
sz := rv.NumField()
for i := 0; i < sz; i++ {
if err := pkt.decodeStructField(rv.Type().Field(i), rv.Field(i)); err != nil {
return err
}
}
default:
if err := binary.Read(pkt.readWriter, binary.LittleEndian, data); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,19 @@
package pool
import (
"bytes"
"sync"
)
var allocator = &sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func Get() *bytes.Buffer {
return allocator.Get().(*bytes.Buffer)
}
func Put(buf *bytes.Buffer) {
buf.Reset()
allocator.Put(buf)
}

4965
internal/protocol/structs.go Normal file

File diff suppressed because it is too large Load Diff

52
internal/redis/login.go Normal file
View File

@@ -0,0 +1,52 @@
package redis
import (
"encoding/json"
"fmt"
"strconv"
"github.com/CPunch/gopenfusion/config"
)
type LoginMetadata struct {
FEKey []byte `json:",omitempty"`
PlayerID int32 `json:",omitempty"`
AccountID int `json:",omitempty"`
}
// we store login queues into redis with the name "loginMetadata_<serialKey>"
// set to expire after config.LOGIN_TIMEOUT duration. this way we can easily
// have a shared pool of active serial keys & player login data which any
// shard can pull from
func makeLoginMetadataKey(serialKey int64) string {
return fmt.Sprintf("loginMetadata_%s", strconv.Itoa(int(serialKey)))
}
func (r *RedisHandler) QueueLogin(serialKey int64, data LoginMetadata) error {
value, err := json.Marshal(data)
if err != nil {
return err
}
// add to table
return r.client.Set(r.ctx, makeLoginMetadataKey(serialKey), value, config.LOGIN_TIMEOUT).Err()
}
func (r *RedisHandler) GetLogin(serialKey int64) (LoginMetadata, error) {
value, err := r.client.Get(r.ctx, makeLoginMetadataKey(serialKey)).Result()
if err != nil {
return LoginMetadata{}, err
}
var data LoginMetadata
if err := json.Unmarshal([]byte(value), &data); err != nil {
return LoginMetadata{}, err
}
return data, nil
}
func (r *RedisHandler) RemoveLogin(serialKey int64) error {
return r.client.Del(r.ctx, makeLoginMetadataKey(serialKey)).Err()
}

36
internal/redis/redis.go Normal file
View File

@@ -0,0 +1,36 @@
package redis
/*
used for state management between shard and login servers
*/
import (
"context"
"github.com/CPunch/gopenfusion/config"
"github.com/redis/go-redis/v9"
)
type RedisHandler struct {
client *redis.Client
ctx context.Context
}
const (
SHARD_SET = "shards"
)
func OpenRedis(addr string) (*RedisHandler, error) {
client := redis.NewClient(&redis.Options{
Addr: addr,
CredentialsProvider: config.GetRedisCredentials(),
})
_, err := client.Ping(context.Background()).Result()
return &RedisHandler{client: client, ctx: context.Background()}, err
}
func (r *RedisHandler) Close() error {
return r.client.Close()
}

34
internal/redis/shard.go Normal file
View File

@@ -0,0 +1,34 @@
package redis
import "encoding/json"
type ShardMetadata struct {
IP string
Port int
}
func (r *RedisHandler) RegisterShard(shard ShardMetadata) error {
value, err := json.Marshal(shard)
if err != nil {
return err
}
return r.client.SAdd(r.ctx, SHARD_SET, value).Err()
}
func (r *RedisHandler) GetShards() []ShardMetadata {
shardData := r.client.SMembers(r.ctx, SHARD_SET).Val()
// unmarshal all shards
shards := make([]ShardMetadata, 0, len(shardData))
for _, data := range shardData {
var shard ShardMetadata
if err := json.Unmarshal([]byte(data), &shard); err != nil {
continue
}
shards = append(shards, shard)
}
return shards
}