From ab39609f456fdf670b484b0d9ac3a00bb2af3edd Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Sat, 28 Dec 2019 13:06:28 +0000 Subject: [PATCH] Implement Save-Game Editor (#1) Signed-off-by: Knut Ahlers --- cmd/sii-editor/.gitignore | 1 + cmd/sii-editor/Makefile | 7 + cmd/sii-editor/api.go | 29 ++ cmd/sii-editor/api_gameinfo.go | 81 +++++ cmd/sii-editor/api_profiles.go | 97 ++++++ cmd/sii-editor/api_saves.go | 312 ++++++++++++++++++++ cmd/sii-editor/base.go | 83 ++++++ cmd/sii-editor/ci/bundle_js.sh | 20 ++ cmd/sii-editor/ci/fontawesome.sh | 13 + cmd/sii-editor/config.go | 53 ++++ cmd/sii-editor/frontend.go | 14 + cmd/sii-editor/frontend/.eslintrc.js | 83 ++++++ cmd/sii-editor/frontend/.gitignore | 3 + cmd/sii-editor/frontend/app.css | 31 ++ cmd/sii-editor/frontend/app.js | 375 ++++++++++++++++++++++++ cmd/sii-editor/frontend/index.html | 260 ++++++++++++++++ cmd/sii-editor/frontend/truck_grp.m.svg | 1 + cmd/sii-editor/frontend/truck_grp.svg | 92 ++++++ cmd/sii-editor/go.mod | 24 ++ cmd/sii-editor/go.sum | 62 ++++ cmd/sii-editor/locale.go | 72 +++++ cmd/sii-editor/main.go | 98 +++++++ cmd/sii-editor/paths.go | 49 ++++ cmd/sii-editor/paths_linux.go | 15 + cmd/sii-editor/paths_windows.go | 15 + cmd/sii-editor/profiles.go | 123 ++++++++ cmd/sii-editor/saves.go | 246 ++++++++++++++++ t3nk/3nk.go | 118 ++++++++ t3nk/3nk_test.go | 31 ++ t3nk/go.mod | 5 + t3nk/go.sum | 2 + 31 files changed, 2415 insertions(+) create mode 100644 cmd/sii-editor/.gitignore create mode 100644 cmd/sii-editor/Makefile create mode 100644 cmd/sii-editor/api.go create mode 100644 cmd/sii-editor/api_gameinfo.go create mode 100644 cmd/sii-editor/api_profiles.go create mode 100644 cmd/sii-editor/api_saves.go create mode 100644 cmd/sii-editor/base.go create mode 100644 cmd/sii-editor/ci/bundle_js.sh create mode 100644 cmd/sii-editor/ci/fontawesome.sh create mode 100644 cmd/sii-editor/config.go create mode 100644 cmd/sii-editor/frontend.go create mode 100644 cmd/sii-editor/frontend/.eslintrc.js create mode 100644 cmd/sii-editor/frontend/.gitignore create mode 100644 cmd/sii-editor/frontend/app.css create mode 100644 cmd/sii-editor/frontend/app.js create mode 100644 cmd/sii-editor/frontend/index.html create mode 100644 cmd/sii-editor/frontend/truck_grp.m.svg create mode 100644 cmd/sii-editor/frontend/truck_grp.svg create mode 100644 cmd/sii-editor/go.mod create mode 100644 cmd/sii-editor/go.sum create mode 100644 cmd/sii-editor/locale.go create mode 100644 cmd/sii-editor/main.go create mode 100644 cmd/sii-editor/paths.go create mode 100644 cmd/sii-editor/paths_linux.go create mode 100644 cmd/sii-editor/paths_windows.go create mode 100644 cmd/sii-editor/profiles.go create mode 100644 cmd/sii-editor/saves.go create mode 100644 t3nk/3nk.go create mode 100644 t3nk/3nk_test.go create mode 100644 t3nk/go.mod create mode 100644 t3nk/go.sum diff --git a/cmd/sii-editor/.gitignore b/cmd/sii-editor/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/cmd/sii-editor/.gitignore @@ -0,0 +1 @@ +.env diff --git a/cmd/sii-editor/Makefile b/cmd/sii-editor/Makefile new file mode 100644 index 0000000..b20f66d --- /dev/null +++ b/cmd/sii-editor/Makefile @@ -0,0 +1,7 @@ +export FA_VERSION=5.12.0 + +default: + +assets: + bash ci/bundle_js.sh + bash ci/fontawesome.sh diff --git a/cmd/sii-editor/api.go b/cmd/sii-editor/api.go new file mode 100644 index 0000000..f684e0e --- /dev/null +++ b/cmd/sii-editor/api.go @@ -0,0 +1,29 @@ +package main + +import ( + "encoding/json" + "net/http" +) + +func apiGenericError(w http.ResponseWriter, status int, err error) { + var eString = "undefined error" + if err != nil { + eString = err.Error() + } + + data := map[string]interface{}{ + "code": status, + "error": eString, + "success": false, + } + + apiGenericJSONResponse(w, status, data) +} + +func apiGenericJSONResponse(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + json.NewEncoder(w).Encode(data) +} diff --git a/cmd/sii-editor/api_gameinfo.go b/cmd/sii-editor/api_gameinfo.go new file mode 100644 index 0000000..0c446dd --- /dev/null +++ b/cmd/sii-editor/api_gameinfo.go @@ -0,0 +1,81 @@ +package main + +import ( + "net/http" + "strings" + + "github.com/Luzifer/sii" + "github.com/gorilla/mux" + "github.com/pkg/errors" +) + +func init() { + router.HandleFunc("/api/gameinfo/cargo", handleListCargo).Methods(http.MethodGet) + router.HandleFunc("/api/profiles/{profileID}/saves/{saveFolder}/companies", handleListCompanies).Methods(http.MethodGet) +} + +type commCargo struct { + Name string `json:"name"` + Mass float32 `json:"mass"` +} + +type commCompany struct { + City string `json:"city"` + Name string `json:"name"` +} + +func handleListCargo(w http.ResponseWriter, r *http.Request) { + var result = map[string]commCargo{} + + for _, b := range baseGameUnit.BlocksByClass("cargo_data") { + c := b.(*sii.CargoData) + var cName = c.CargoName + if strings.HasPrefix(cName, "@@") { + // Localization string, translate + cName = locale.GetTranslation(cName) + } + + result[c.Name()] = commCargo{ + Name: cName, + Mass: c.Mass, + } + } + + apiGenericJSONResponse(w, http.StatusOK, result) +} + +func handleListCompanies(w http.ResponseWriter, r *http.Request) { + var ( + result = map[string]commCompany{} + vars = mux.Vars(r) + ) + + game, _, err := loadSave(vars["profileID"], vars["saveFolder"]) + if err != nil { + apiGenericError(w, http.StatusInternalServerError, errors.Wrap(err, "Unable to load save")) + return + } + + for _, b := range game.BlocksByClass("company") { + c, ok := b.(*sii.Company) + if !ok { + // Should not happen but to be sure... + continue + } + + cName := baseGameUnit.BlockByName(c.CityPtr().Target).(*sii.CityData).CityNameLocalized + if strings.HasPrefix(cName, "@@") { + cName = locale.GetTranslation(cName) + if cName == strings.Trim(baseGameUnit.BlockByName(c.CityPtr().Target).(*sii.CityData).CityNameLocalized, "@") { + cName = baseGameUnit.BlockByName(c.CityPtr().Target).(*sii.CityData).CityName + } + } + + result[c.Name()] = commCompany{ + City: cName, + Name: baseGameUnit.BlockByName(c.PermanentData.Target).(*sii.CompanyPermanent).CompanyName, + } + } + + apiGenericJSONResponse(w, http.StatusOK, result) +} diff --git a/cmd/sii-editor/api_profiles.go b/cmd/sii-editor/api_profiles.go new file mode 100644 index 0000000..fcf513c --- /dev/null +++ b/cmd/sii-editor/api_profiles.go @@ -0,0 +1,97 @@ +package main + +import ( + "net/http" + "time" + + "github.com/gorilla/mux" + "github.com/gorilla/websocket" + log "github.com/sirupsen/logrus" +) + +const savePollTime = 10 * time.Second + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +func init() { + router.HandleFunc("/api/profiles", handleListProfiles).Methods(http.MethodGet) + router.HandleFunc("/api/profiles/{profileID}/saves", handleGetProfileSaves).Methods(http.MethodGet) +} + +func handleGetProfileSaves(w http.ResponseWriter, r *http.Request) { + var ( + subscribe = r.FormValue("subscribe") == "true" + vars = mux.Vars(r) + ) + + saves, err := listSaves(vars["profileID"]) + if err != nil { + apiGenericError(w, http.StatusInternalServerError, err) + return + } + + if !subscribe { + apiGenericJSONResponse(w, http.StatusOK, saves) + return + } + + // Remember latest save + var latestSave time.Time + for _, s := range saves { + if s.SaveTime.After(latestSave) { + latestSave = s.SaveTime + } + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.WithError(err).Debug("Unable to open websocket") + return + } + defer conn.Close() + + if err = conn.WriteJSON(saves); err != nil { + log.WithError(err).Debug("Unable to send saves list") + return + } + + for t := time.NewTicker(savePollTime); ; <-t.C { + saves, err = listSaves(vars["profileID"]) + if err != nil { + log.WithError(err).Error("Unable to list saves during socket") + return + } + + var newSaveTime time.Time + for _, s := range saves { + if s.SaveTime.After(latestSave) { + newSaveTime = s.SaveTime + } + } + + if newSaveTime.IsZero() { + // Nothing new + continue + } + + if err = conn.WriteJSON(saves); err != nil { + log.WithError(err).Error("Unable to send saves list") + return + } + + latestSave = newSaveTime + } +} + +func handleListProfiles(w http.ResponseWriter, r *http.Request) { + profiles, err := listProfiles() + if err != nil { + apiGenericError(w, http.StatusInternalServerError, err) + return + } + + apiGenericJSONResponse(w, http.StatusOK, profiles) +} diff --git a/cmd/sii-editor/api_saves.go b/cmd/sii-editor/api_saves.go new file mode 100644 index 0000000..62ccdc1 --- /dev/null +++ b/cmd/sii-editor/api_saves.go @@ -0,0 +1,312 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "github.com/Luzifer/go_helpers/v2/str" + "github.com/Luzifer/sii" + "github.com/gorilla/mux" + "github.com/pkg/errors" +) + +const ( + storeSaveFolder = "sii_editor" + storeSaveName = "SII Editor" +) + +func init() { + router.HandleFunc("/api/profiles/{profileID}/saves/{saveFolder}", handleGetSaveInfo).Methods(http.MethodGet) + router.HandleFunc("/api/profiles/{profileID}/saves/{saveFolder}/economy", handleUpdateEconomyInfo).Methods(http.MethodPut) + router.HandleFunc("/api/profiles/{profileID}/saves/{saveFolder}/fix", handleFixPlayer).Methods(http.MethodPut) + router.HandleFunc("/api/profiles/{profileID}/saves/{saveFolder}/jobs", handleListJobs).Methods(http.MethodGet) + router.HandleFunc("/api/profiles/{profileID}/saves/{saveFolder}/jobs", handleAddJob).Methods(http.MethodPost) + router.HandleFunc("/api/profiles/{profileID}/saves/{saveFolder}/set-trailer", handleSetTrailer).Methods(http.MethodPut) +} + +func handleAddJob(w http.ResponseWriter, r *http.Request) { + var ( + job commSaveJob + vars = mux.Vars(r) + ) + + if err := json.NewDecoder(r.Body).Decode(&job); err != nil { + apiGenericError(w, http.StatusBadRequest, errors.Wrap(err, "Unable to decode input data")) + return + } + + game, info, err := loadSave(vars["profileID"], vars["saveFolder"]) + if err != nil { + apiGenericError(w, http.StatusInternalServerError, errors.Wrap(err, "Unable to load save")) + return + } + + info.SaveName = storeSaveName + info.FileTime = time.Now().Unix() + + // Set urgency if it isn't + if job.Urgency == nil { + u := int64(0) + job.Urgency = &u + } + + if job.Weight == 0 { + job.Weight = 10000 // 10 tons as a default + } + + if job.Weight < 100 { + // User clearly did't want 54kg but 54 tons! (If not: screw em) + job.Weight *= 1000 + } + + if job.Distance == 0 { + job.Distance = 100 // If the user did not provide distance use 100km as a default + } + + // Get company + company := game.BlockByName(job.OriginReference).(*sii.Company) + // Get cargo + cargo := baseGameUnit.BlockByName(job.CargoReference).(*sii.CargoData) + + // Get trailer / truck from other jobs + var ( + cTruck, cTV, cTD string + cTP []sii.Placement + ) + + for _, jp := range company.JobOffer { + j := jp.Resolve().(*sii.JobOfferData) + if j.CompanyTruck.Target == "null" || j.TrailerVariant.Target == "null" || j.TrailerDefinition.Target == "null" || len(j.TrailerPlace) < 1 { + continue + } + cTruck, cTV, cTD = j.CompanyTruck.Target, j.TrailerVariant.Target, j.TrailerDefinition.Target + cTP = j.TrailerPlace + break + } + + if cTP == nil { + // The company did not have any valid job offers to steal from, lets search globally + for _, jb := range game.BlocksByClass("job_offer_data") { + j := jb.(*sii.JobOfferData) + if j.CompanyTruck.Target == "null" || j.TrailerVariant.Target == "null" || j.TrailerDefinition.Target == "null" || len(j.TrailerPlace) < 1 { + continue + } + cTruck, cTV, cTD = j.CompanyTruck.Target, j.TrailerVariant.Target, j.TrailerDefinition.Target + cTP = j.TrailerPlace + break + } + } + + jobID := "_nameless." + strconv.FormatInt(time.Now().Unix(), 16) + exTime := game.BlocksByClass("economy")[0].(*sii.Economy).GameTime + 300 // 300min = 5h + j := &sii.JobOfferData{ + // User requested job data + Target: strings.TrimPrefix(job.TargetReference, "company.volatile."), + ExpirationTime: &exTime, + Urgency: job.Urgency, + Cargo: sii.Ptr{Target: job.CargoReference}, + UnitsCount: int64(job.Weight / cargo.Mass), + ShortestDistanceKM: job.Distance, + + // Some static data + FillRatio: 1, // Dunno but other jobs have it at 1, so keep for now + + // Dunno where this data comes from, steal it from previous first job + TrailerPlace: cTP, + + // Too lazy to implement, just steal it too + CompanyTruck: sii.Ptr{Target: cTruck}, + TrailerVariant: sii.Ptr{Target: cTV}, + TrailerDefinition: sii.Ptr{Target: cTD}, + } + j.Init("", jobID) + + // Add the new job to the game + game.Entries = append(game.Entries, j) + company.JobOffer = append([]sii.Ptr{{Target: j.Name()}}, company.JobOffer...) + + // Write the save-game + if err = storeSave(vars["profileID"], storeSaveFolder, game, info); err != nil { + apiGenericError(w, http.StatusInternalServerError, errors.Wrap(err, "Unable to store save")) + return + } + + apiGenericJSONResponse(w, http.StatusOK, map[string]interface{}{"success": true}) +} + +func handleFixPlayer(w http.ResponseWriter, r *http.Request) { + var ( + fixType = fixAll + vars = mux.Vars(r) + ) + + if v := r.FormValue("type"); v != "" { + fixType = v + } + + game, info, err := loadSave(vars["profileID"], vars["saveFolder"]) + if err != nil { + apiGenericError(w, http.StatusInternalServerError, errors.Wrap(err, "Unable to load save")) + return + } + + info.SaveName = storeSaveName + info.FileTime = time.Now().Unix() + + if err = fixPlayerTruck(game, fixType); err != nil { + apiGenericError(w, http.StatusInternalServerError, errors.Wrap(err, "Unable to apply fixes")) + return + } + + if err = storeSave(vars["profileID"], storeSaveFolder, game, info); err != nil { + apiGenericError(w, http.StatusInternalServerError, errors.Wrap(err, "Unable to store save")) + return + } + + apiGenericJSONResponse(w, http.StatusOK, map[string]interface{}{"success": true}) +} + +func handleGetSaveInfo(w http.ResponseWriter, r *http.Request) { + var vars = mux.Vars(r) + + game, _, err := loadSave(vars["profileID"], vars["saveFolder"]) + if err != nil { + apiGenericError(w, http.StatusInternalServerError, errors.Wrap(err, "Unable to load save")) + return + } + + info, err := commSaveDetailsFromUnit(game) + if err != nil { + apiGenericError(w, http.StatusInternalServerError, errors.Wrap(err, "Unable to gather info")) + return + } + + apiGenericJSONResponse(w, http.StatusOK, info) +} + +func handleListJobs(w http.ResponseWriter, r *http.Request) { + var ( + result []commSaveJob + vars = mux.Vars(r) + undiscovered = r.FormValue("undiscovered") == "true" + ) + + game, _, err := loadSave(vars["profileID"], vars["saveFolder"]) + if err != nil { + apiGenericError(w, http.StatusInternalServerError, errors.Wrap(err, "Unable to load save")) + return + } + + economy := game.BlocksByClass("economy")[0].(*sii.Economy) + var visitedCities []string + for _, p := range economy.VisitedCities { + visitedCities = append(visitedCities, p.Target) + } + + for _, cb := range game.BlocksByClass("company") { + c := cb.(*sii.Company) + + cityName := strings.TrimPrefix(c.CityPtr().Target, "city.") // The "VisitedCities" pointers are kinda broken and do not contain the "city." part + if !str.StringInSlice(cityName, visitedCities) && !undiscovered { + continue + } + + for _, b := range c.JobOffer { + j := b.Resolve().(*sii.JobOfferData) + + if j.Target == "" || *j.ExpirationTime < economy.GameTime { + continue + } + + result = append(result, commSaveJob{ + OriginReference: c.Name(), + TargetReference: strings.Join([]string{"company", "volatile", j.Target}, "."), + CargoReference: j.Cargo.Target, + Distance: j.ShortestDistanceKM, + Urgency: j.Urgency, + Expires: *j.ExpirationTime - economy.GameTime, + }) + } + } + + apiGenericJSONResponse(w, http.StatusOK, result) +} + +func handleSetTrailer(w http.ResponseWriter, r *http.Request) { + var ( + reference = r.FormValue("ref") + vars = mux.Vars(r) + ) + + game, info, err := loadSave(vars["profileID"], vars["saveFolder"]) + if err != nil { + apiGenericError(w, http.StatusInternalServerError, errors.Wrap(err, "Unable to load save")) + return + } + + if game.BlockByName(reference) == nil { + apiGenericError(w, http.StatusBadRequest, errors.New("Invalid reference given")) + return + } + + info.SaveName = storeSaveName + info.FileTime = time.Now().Unix() + + game.BlocksByClass("player")[0].(*sii.Player).AssignedTrailer = sii.Ptr{Target: reference} + + if err = storeSave(vars["profileID"], storeSaveFolder, game, info); err != nil { + apiGenericError(w, http.StatusInternalServerError, errors.Wrap(err, "Unable to store save")) + return + } + + apiGenericJSONResponse(w, http.StatusOK, map[string]interface{}{"success": true}) +} + +func handleUpdateEconomyInfo(w http.ResponseWriter, r *http.Request) { + var vars = mux.Vars(r) + + game, info, err := loadSave(vars["profileID"], vars["saveFolder"]) + if err != nil { + apiGenericError(w, http.StatusInternalServerError, errors.Wrap(err, "Unable to load save")) + return + } + + info.SaveName = storeSaveName + info.FileTime = time.Now().Unix() + + blocks := game.BlocksByClass("economy") + if len(blocks) != 1 { + // expecting exactly one economy block + apiGenericError(w, http.StatusInternalServerError, errors.New("Did not find economy block")) + return + } + economy := blocks[0].(*sii.Economy) + + if xpRaw := r.FormValue("xp"); xpRaw != "" { + xp, err := strconv.ParseInt(xpRaw, 10, 64) + if err != nil { + apiGenericError(w, http.StatusBadRequest, errors.Wrap(err, "Invalid value to xp parameter")) + return + } + economy.ExperiencePoints = xp + } + + if moneyRaw := r.FormValue("money"); moneyRaw != "" { + money, err := strconv.ParseInt(moneyRaw, 10, 64) + if err != nil { + apiGenericError(w, http.StatusBadRequest, errors.Wrap(err, "Invalid value to money parameter")) + return + } + economy.Bank.Resolve().(*sii.Bank).MoneyAccount = money + } + + if err = storeSave(vars["profileID"], storeSaveFolder, game, info); err != nil { + apiGenericError(w, http.StatusInternalServerError, errors.Wrap(err, "Unable to store save")) + return + } + + apiGenericJSONResponse(w, http.StatusOK, map[string]interface{}{"success": true}) +} diff --git a/cmd/sii-editor/base.go b/cmd/sii-editor/base.go new file mode 100644 index 0000000..68d4fb4 --- /dev/null +++ b/cmd/sii-editor/base.go @@ -0,0 +1,83 @@ +package main + +import ( + "bytes" + "io" + "io/ioutil" + "os" + "path" + "regexp" + "strings" + + "github.com/Luzifer/scs-extract/scs" + "github.com/Luzifer/sii" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +var baseDataFiles = regexp.MustCompile(`^def/(?:cargo|city|company)/[^/]+.sii$`) + +func readBaseData() (*sii.Unit, error) { + var unitData = new(bytes.Buffer) + // Open a plain unit for parsing + unitData.WriteString("SiiNunit\n{\n") + + // Collect all available units from game files + entries, err := ioutil.ReadDir(getGamePath()) + if err != nil { + return nil, errors.Wrap(err, "Unable to list game-directory") + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".scs") { + // We don't care for anthing than SCS# files + continue + } + + fPath := path.Join(getGamePath(), entry.Name()) + + stat, err := os.Stat(fPath) + if err != nil { + return nil, errors.Wrap(err, "Unable to stat SCS# archive") + } + + scsFile, err := os.Open(fPath) + if err != nil { + return nil, errors.Wrap(err, "Unable to open SCS# archive") + } + defer scsFile.Close() + + r, err := scs.NewReader(scsFile, stat.Size()) + if err != nil { + // There are some files being unreadable, that's okay + log.WithField("file", entry.Name()).Debug("Found unreadable SCS archive") + continue + } + + for _, f := range r.Files { + if !baseDataFiles.MatchString(f.Name) { + // We don't care for most of the files, just mentioned definitions + continue + } + + fr, err := f.Open() + if err != nil { + return nil, errors.Wrap(err, "Unable to open file from SCS archive") + } + + if _, err = io.Copy(unitData, fr); err != nil { + return nil, errors.Wrap(err, "Unable to read file from SCS archive") + } + + unitData.WriteString("\n") // Ensure CR after each block + + fr.Close() + } + } + + // Close unit + unitData.WriteString("}") + + // Read-in constructed unit file + return sii.ParseSIIPlainFile(unitData) +} diff --git a/cmd/sii-editor/ci/bundle_js.sh b/cmd/sii-editor/ci/bundle_js.sh new file mode 100644 index 0000000..441c6fa --- /dev/null +++ b/cmd/sii-editor/ci/bundle_js.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -euxo pipefail + +css_deps=( + npm/bootstrap@4/dist/css/bootstrap.min.css + npm/bootswatch@4/dist/darkly/bootstrap.min.css + npm/bootstrap-vue@2/dist/bootstrap-vue.min.css +) + +js_deps=( + npm/vue@2/dist/vue.min.js + npm/bootstrap-vue@2/dist/bootstrap-vue.min.js + npm/axios@0.19.0/dist/axios.min.js + npm/moment@2.24.0/min/moment.min.js +) + +IFS=',' + +curl -sSfLo frontend/combine.js "https://cdn.jsdelivr.net/combine/${js_deps[*]}" +curl -sSfLo frontend/combine.css "https://cdn.jsdelivr.net/combine/${css_deps[*]}" diff --git a/cmd/sii-editor/ci/fontawesome.sh b/cmd/sii-editor/ci/fontawesome.sh new file mode 100644 index 0000000..dc5da86 --- /dev/null +++ b/cmd/sii-editor/ci/fontawesome.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euxo pipefail + +# Ensure deletion of older version +rm -rf frontend/fontawesome + +# Download and unpack fontawesome-free +curl -sSfLo frontend/fa.zip "https://use.fontawesome.com/releases/v${FA_VERSION}/fontawesome-free-${FA_VERSION}-web.zip" +unzip frontend/fa.zip -d frontend +rm frontend/fa.zip + +# Move to generic path +mv frontend/fontawesome-free-${FA_VERSION}-web frontend/fontawesome diff --git a/cmd/sii-editor/config.go b/cmd/sii-editor/config.go new file mode 100644 index 0000000..36cb9ee --- /dev/null +++ b/cmd/sii-editor/config.go @@ -0,0 +1,53 @@ +package main + +import ( + "os" + + "github.com/pkg/errors" + "gopkg.in/yaml.v2" +) + +var errUserConfigNotFound = errors.New("User config not found") + +type configFile struct { + GameDirectories map[string]string `yaml:"game_directories"` + ProfileDirectories map[string]string `yaml:"profile_directories"` +} + +func (c *configFile) loadDefaults() error { + for k, dv := range gamePaths { + if c.GameDirectories[k] == "" { + c.GameDirectories[k] = dv + } + } + + for k, dv := range profilePaths { + if c.ProfileDirectories[k] == "" { + c.ProfileDirectories[k] = dv + } + } + + return nil +} + +func loadUserConfig(p string) (*configFile, error) { + var c = &configFile{ + GameDirectories: map[string]string{}, + ProfileDirectories: map[string]string{}, + } + + if _, err := os.Stat(p); err != nil { + if os.IsNotExist(err) { + return c, errUserConfigNotFound + } + return c, errors.Wrap(err, "Unable to stat user config") + } + + f, err := os.Open(p) + if err != nil { + return c, errors.Wrap(err, "Unable to open user config") + } + defer f.Close() + + return c, errors.Wrap(yaml.NewDecoder(f).Decode(&c), "Unable to read user config") +} diff --git a/cmd/sii-editor/frontend.go b/cmd/sii-editor/frontend.go new file mode 100644 index 0000000..b02b942 --- /dev/null +++ b/cmd/sii-editor/frontend.go @@ -0,0 +1,14 @@ +package main + +import "net/http" + +func init() { + router.HandleFunc("/", handleIndexPage).Methods(http.MethodGet) + router.PathPrefix("/asset/").Handler( + http.StripPrefix("/asset/", http.FileServer(http.Dir("frontend"))), + ).Methods(http.MethodGet) +} + +func handleIndexPage(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "frontend/index.html") +} diff --git a/cmd/sii-editor/frontend/.eslintrc.js b/cmd/sii-editor/frontend/.eslintrc.js new file mode 100644 index 0000000..d27c95b --- /dev/null +++ b/cmd/sii-editor/frontend/.eslintrc.js @@ -0,0 +1,83 @@ +// https://eslint.org/docs/user-guide/configuring + +module.exports = { + 'root': true, + 'parserOptions': { + parser: 'babel-eslint', + ecmaVersion: 2018, + }, + 'env': { + browser: true, + }, + 'extends': [ + // https://github.com/standard/standard/blob/master/docs/RULES-en.md + 'eslint:recommended', + ], + 'globals': { + process: true, + Vue: true, + axios: true, + moment: true, + }, + // add your custom rules here + 'rules': { + 'array-bracket-newline': ['error', { multiline: true }], + 'array-bracket-spacing': ['error'], + 'arrow-body-style': ['error', 'as-needed'], + 'arrow-parens': ['error', 'as-needed'], + 'arrow-spacing': ['error', { before: true, after: true }], + 'block-spacing': ['error'], + 'brace-style': ['error', '1tbs'], + 'comma-dangle': ['error', 'always-multiline'], // Apply Contentflow rules + 'comma-spacing': ['error'], + 'comma-style': ['error', 'last'], + 'curly': ['error'], + 'dot-location': ['error', 'property'], + 'dot-notation': ['error'], + 'eol-last': ['error', 'always'], + 'eqeqeq': ['error', 'always', { 'null': 'ignore' }], + 'func-call-spacing': ['error', 'never'], + 'function-paren-newline': ['error', 'multiline'], + 'generator-star-spacing': ['off'], // allow async-await + 'implicit-arrow-linebreak': ['error'], + 'indent': ['error', 2], + 'key-spacing': ['error', { beforeColon: false, afterColon: true, mode: 'strict' }], + 'keyword-spacing': ['error'], + 'linebreak-style': ['error', 'unix'], + 'lines-between-class-members': ['error'], + 'multiline-comment-style': ['warn'], + 'newline-per-chained-call': ['error'], + 'no-console': ['off'], + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', // allow debugger during development + 'no-else-return': ['error'], + 'no-extra-parens': ['error'], + 'no-implicit-coercion': ['error'], + 'no-lonely-if': ['error'], + 'no-multiple-empty-lines': ['warn', { max: 2, maxEOF: 0, maxBOF: 0 }], + 'no-multi-spaces': ['error'], + 'no-trailing-spaces': ['error'], + 'no-unneeded-ternary': ['error'], + 'no-useless-return': ['error'], + 'no-whitespace-before-property': ['error'], + 'object-curly-newline': ['error', { consistent: true }], + 'object-curly-spacing': ['error', 'always'], + 'object-shorthand': ['error'], + 'padded-blocks': ['error', 'never'], + 'prefer-arrow-callback': ['error'], + 'prefer-const': ['error'], + 'prefer-object-spread': ['error'], + 'prefer-template': ['error'], + 'quote-props': ['error', 'consistent-as-needed', { keywords: true }], + 'quotes': ['error', 'single', { allowTemplateLiterals: true }], + 'semi': ['error', 'never'], + 'space-before-blocks': ['error', 'always'], + 'spaced-comment': ['warn', 'always'], + 'space-infix-ops': ['error'], + 'space-in-parens': ['error', 'never'], + 'space-unary-ops': ['error', { words: true, nonwords: false }], + 'switch-colon-spacing': ['error'], + 'unicode-bom': ['error', 'never'], + 'wrap-iife': ['error'], + 'yoda': ['error'], + }, +} diff --git a/cmd/sii-editor/frontend/.gitignore b/cmd/sii-editor/frontend/.gitignore new file mode 100644 index 0000000..c6d9f73 --- /dev/null +++ b/cmd/sii-editor/frontend/.gitignore @@ -0,0 +1,3 @@ +combine.css +combine.js +fontawesome diff --git a/cmd/sii-editor/frontend/app.css b/cmd/sii-editor/frontend/app.css new file mode 100644 index 0000000..1050c57 --- /dev/null +++ b/cmd/sii-editor/frontend/app.css @@ -0,0 +1,31 @@ +body { font-family: sans-serif; } + +.dmg { height: 200px; } + +.dmg svg { width: 100%; height: 100%; } + +.dmg.truckok #truck { fill: #008000 !important; } +.dmg.truckok #truckdmg { fill: #fff !important; } +.dmg.truckwarn #truck { fill: #FFCC00 !important; } +.dmg.truckwarn #truckdmg { fill: #000 !important; } +.dmg.truckdanger #truck { fill: #CA0B00 !important; } +.dmg.truckdanger #truckdmg { fill: #fff !important; } + +.dmg.trailerna #trailer { fill: rgba(255, 255, 255, 0.05) !important; } +.dmg.trailerna #trailerdmg { display: none; } +.dmg.trailerok #trailer { fill: #008000 !important; } +.dmg.trailerok #trailerdmg { fill: #fff !important; } +.dmg.trailerwarn #trailer { fill: #FFCC00 !important; } +.dmg.trailerwarn #trailerdmg { fill: #000 !important; } +.dmg.trailerdanger #trailer { fill: #CA0B00 !important; } +.dmg.trailerdanger #trailerdmg { fill: #fff !important; } + +.dmg #cargo { box-shadow: 0px 0px 5px white; } +.dmg.cargona #cargo { display: none; } +.dmg.cargona #cargodmg { display: none; } +.dmg.cargook #cargo { fill: #008000 !important; } +.dmg.cargook #cargodmg { fill: #fff !important; } +.dmg.cargowarn #cargo { fill: #FFCC00 !important; } +.dmg.cargowarn #cargodmg { fill: #000 !important; } +.dmg.cargodanger #cargo { fill: #CA0B00 !important; } +.dmg.cargodanger #cargodmg { fill: #fff !important; } diff --git a/cmd/sii-editor/frontend/app.js b/cmd/sii-editor/frontend/app.js new file mode 100644 index 0000000..3c8c40a --- /dev/null +++ b/cmd/sii-editor/frontend/app.js @@ -0,0 +1,375 @@ +const colorThresholds = { + ok: 0, + warn: 10, + danger: 50, +} + +window.app = new Vue({ + + computed: { + cargoSelectItems() { + const result = [] + + for (const ref in this.cargo) { + result.push({ + value: ref, + text: `${this.cargo[ref].name}`, + }) + } + + return result.sort((a, b) => a.text.localeCompare(b.text)) + }, + + companySelectItems() { + const result = [] + + for (const ref in this.companies) { + result.push({ + value: ref, + text: `${this.companies[ref].city}, ${this.companies[ref].name}`, + }) + } + + return result.sort((a, b) => a.text.localeCompare(b.text)) + }, + + dmgCargo() { + return this.save && this.save.cargo_damage * 100 || 0 + }, + + dmgTrailer() { + return this.save && this.save.trailer_wear * 100 || 0 + }, + + dmgTruck() { + return this.save && this.save.truck_wear * 100 || 0 + }, + + jobFields() { + return [ + { + key: 'origin_reference', + label: 'Origin', + sortable: true, + }, + { + key: 'target_reference', + label: 'Target', + sortable: true, + }, + { + key: 'cargo_reference', + label: 'Cargo', + sortable: true, + }, + { + key: 'distance', + label: 'Dist', + sortable: true, + }, + { + key: 'urgency', + label: 'Urg', + sortable: true, + }, + { + key: 'expires', + label: 'Exp', + sortable: true, + }, + ] + }, + + ownedTrailers() { + const trailers = [] + + if (this.save && this.save.current_job && this.save.current_job.trailer_reference) { + trailers.push({ value: this.save.current_job.trailer_reference, text: 'Company Trailer' }) + } + + let attachedTrailerOwned = false + for (const id in this.save.owned_trailers) { + if (id === this.save.attached_trailer) { + attachedTrailerOwned = true + } + trailers.push({ value: id, text: this.save.owned_trailers[id] }) + } + + if (this.save && this.save.trailer_attached && !attachedTrailerOwned && (!this.save.current_job || this.save.trailer_attached !== this.save.current_job.trailer_reference)) { + trailers.push({ value: this.save.attached_trailer, text: 'Other Trailer' }) + } + + return trailers + }, + + sortedSaves() { + const saves = [] + + for (const id in this.saves) { + const name = this.saves[id].name !== '' ? this.saves[id].name : this.saveIDToName(id) + saves.push({ + ...this.saves[id], + id, + name, + }) + } + + return saves.sort((b, a) => new Date(a.save_time) - new Date(b.save_time)) + }, + + truckClass() { + const classes = ['dmg'] + + const classSelector = (prefix, value) => { + for (const t of ['danger', 'warn', 'ok']) { + if (value >= colorThresholds[t]) { + return `${prefix}${t}` + } + } + } + + classes.push(classSelector('truck', this.dmgTruck)) + + if (this.save && this.save.trailer_attached) { + classes.push(classSelector('trailer', this.dmgTrailer)) + classes.push(classSelector('cargo', this.dmgCargo)) + } else { + classes.push('trailerna') + classes.push('cargona') + } + + return classes.join(' ') + }, + }, + + created() { + this.loadCargo() + this.loadProfiles() + }, + + data: { + autoLoad: false, + cargo: {}, + companies: {}, + jobs: [], + newJob: { weight: 10 }, + profiles: {}, + save: null, + saveLoading: false, + saves: {}, + selectedProfile: null, + selectedSave: null, + showAutosaves: false, + showSaveModal: false, + socket: null, + }, + + el: '#app', + + methods: { + attachTrailer() { + this.showSaveModal = true + return axios.put(`/api/profiles/${this.selectedProfile}/saves/${this.selectedSave}/set-trailer?ref=${this.save.attached_trailer}`) + .then(() => this.showToast('Success', 'Trailer attached', 'success')) + .catch(err => { + this.showToast('Uhoh…', 'Could not attach trailer', 'danger') + console.error(err) + }) + }, + + createJob() { + if (!this.companies[this.newJob.origin_reference]) { + this.showToast('Uhm…', 'Source Company does not exist', 'danger') + return + } + + if (!this.companies[this.newJob.target_reference]) { + this.showToast('Uhm…', 'Target Company does not exist', 'danger') + return + } + + if (!this.cargo[this.newJob.cargo_reference]) { + this.showToast('Uhm…', 'Cargo does not exist', 'danger') + return + } + + this.newJob.weight = parseInt(this.newJob.weight) + if (this.newJob.weight > 200) { + this.showToast('Uhm…', 'You want to pull more than 200 Tons of cargo?', 'danger') + return + } + + this.showSaveModal = true + + return axios.post(`/api/profiles/${this.selectedProfile}/saves/${this.selectedSave}/jobs`, this.newJob) + .then(() => { + this.showToast('Success', 'Job created', 'success') + this.newJob = { weight: 10 } // Reset job + }) + .catch(err => { + this.showToast('Uhoh…', 'Could not add job', 'danger') + console.error(err) + }) + }, + + executeRepair(fixType) { + this.showSaveModal = true + return axios.put(`/api/profiles/${this.selectedProfile}/saves/${this.selectedSave}/fix?type=${fixType}`) + .then(() => this.showToast('Success', 'Repair executed', 'success')) + .catch(err => { + this.showToast('Uhoh…', 'Could not repair', 'danger') + console.error(err) + }) + }, + + loadCargo() { + return axios.get(`/api/gameinfo/cargo`) + .then(resp => { + this.cargo = resp.data + }) + .catch(err => { + this.showToast('Uhoh…', 'Could not load cargo defintion', 'danger') + console.error(err) + }) + }, + + loadCompanies() { + return axios.get(`/api/profiles/${this.selectedProfile}/saves/${this.selectedSave}/companies`) + .then(resp => { + this.companies = resp.data + }) + .catch(err => { + this.showToast('Uhoh…', 'Could not load company defintion', 'danger') + console.error(err) + }) + }, + + loadJobs() { + return axios.get(`/api/profiles/${this.selectedProfile}/saves/${this.selectedSave}/jobs`) + .then(resp => { + this.jobs = resp.data + }) + .catch(err => { + this.showToast('Uhoh…', 'Could not load jobs', 'danger') + console.error(err) + }) + }, + + loadNewestSave() { + this.selectSave(this.sortedSaves[0].id, null) + }, + + loadProfiles() { + return axios.get('/api/profiles') + .then(resp => { + this.profiles = resp.data + }) + .catch(err => { + this.showToast('Uhoh…', 'Could not load profiles', 'danger') + console.error(err) + }) + }, + + loadSave() { + // Load companies in background + this.loadCompanies() + + this.saveLoading = true + this.showSaveModal = false + return axios.get(`/api/profiles/${this.selectedProfile}/saves/${this.selectedSave}`) + .then(resp => { + this.save = resp.data + this.saveLoading = false + }) + .catch(err => { + this.showToast('Uhoh…', 'Could not load save-game', 'danger') + console.error(err) + }) + }, + + loadSaves() { + if (this.socket) { + // Dispose old socket + this.socket.close() + this.socket = null + } + + const loc = window.location + const socketBase = `${loc.protocol === 'https:' ? 'wss:' : 'ws:'}//${loc.host}` + this.socket = new WebSocket(`${socketBase}/api/profiles/${this.selectedProfile}/saves?subscribe=true`) + this.socket.onclose = () => window.setTimeout(this.loadSaves, 1000) // Restart socket + this.socket.onmessage = evt => { + this.saves = JSON.parse(evt.data) + if (this.autoLoad) { + this.loadNewestSave() + } + } + }, + + saveIDToName(id) { + if (id === 'quicksave') { + return 'Quicksave' + } + + if (id.indexOf('autosave') >= 0) { + return 'Autosave' + } + + return '' + }, + + selectProfile(profileID) { + this.selectedProfile = profileID + }, + + selectSave(saveID) { + if (this.selectedSave === saveID) { + this.loadSave() + } else { + this.selectedSave = saveID + } + }, + + setEconomy(param, value) { + this.showSaveModal = true + return axios.put(`/api/profiles/${this.selectedProfile}/saves/${this.selectedSave}/economy?${param}=${value}`) + .then(() => this.showToast('Success', 'Economy updated', 'success')) + .catch(err => { + this.showToast('Uhoh…', 'Could not update economy', 'danger') + console.error(err) + }) + }, + + showToast(title, text, variant = 'info') { + this.$bvToast.toast(text, { + 'appendToast': true, + 'autoHideDelay': 2500, + 'is-status': true, + 'solid': true, + title, + variant, + }) + }, + + validInt(v) { + return v >= 0 && v <= 2147483647 + }, + }, + + watch: { + autoLoad() { + if (this.autoLoad) { + this.loadNewestSave() + } + }, + + selectedProfile() { + this.loadSaves() + }, + + selectedSave() { + this.loadSave() + }, + }, + +}) diff --git a/cmd/sii-editor/frontend/index.html b/cmd/sii-editor/frontend/index.html new file mode 100644 index 0000000..578eb01 --- /dev/null +++ b/cmd/sii-editor/frontend/index.html @@ -0,0 +1,260 @@ + + + SII-Editor + + + + + +
+ + + SII-Editor + + + + + + + + {{ profile.profile_name }} ({{ profile.company_name }}) + + + + + + + + + + + + + + Latest Save
+ Toggles automatic loading of latest save +
+ +
+ + + {{ save.name.replace('@@noname_save_game@@', 'Save') || 'Autosave' }} + + {{ moment(save.save_time).fromNow() }} +
+
+
+ +
+ Toggle Autosaves +
+
+ + +
+
+ Please select a save at the left +
+
+
+ Please select a save at the bottom +
+
+ + +
+ Loading save... +
+ + + + + + + + + + Set + + + + + + + Set + + + + + + + + + {{ dmgTruck.toFixed(0) }}%{{ dmgTrailer.toFixed(0) }}%{{ dmgCargo.toFixed(0) }}% + + + + + + + + Truck + Trailer + Cargo + Everything + + + + + + + + + + Set + + + + + + + + + + + + + + + + + + + + + + + + + + Create Job + + + + + + + + +
+ +
+ + + + +
+ Please select a profile above +
+
+
+ + +
+
+ Modifying save-game... +
+
+
+ + + + diff --git a/cmd/sii-editor/frontend/truck_grp.m.svg b/cmd/sii-editor/frontend/truck_grp.m.svg new file mode 100644 index 0000000..1bfe773 --- /dev/null +++ b/cmd/sii-editor/frontend/truck_grp.m.svg @@ -0,0 +1 @@ +100%100%100% \ No newline at end of file diff --git a/cmd/sii-editor/frontend/truck_grp.svg b/cmd/sii-editor/frontend/truck_grp.svg new file mode 100644 index 0000000..150b38c --- /dev/null +++ b/cmd/sii-editor/frontend/truck_grp.svg @@ -0,0 +1,92 @@ + + + + + + + + + 100% + 100% + 100% + diff --git a/cmd/sii-editor/go.mod b/cmd/sii-editor/go.mod new file mode 100644 index 0000000..f1e7a40 --- /dev/null +++ b/cmd/sii-editor/go.mod @@ -0,0 +1,24 @@ +module github.com/Luzifer/sii/cmd/sii-editor + +go 1.13 + +replace github.com/Luzifer/sii => ../../ + +replace github.com/Luzifer/sii/t3nk => ../../t3nk + +require ( + github.com/Luzifer/go_helpers/v2 v2.9.1 + github.com/Luzifer/rconfig/v2 v2.2.1 + github.com/Luzifer/scs-extract v0.1.1-0.20191226001718-17f71578850d + github.com/Luzifer/sii v0.0.0-00010101000000-000000000000 + github.com/Luzifer/sii/t3nk v0.0.0-00010101000000-000000000000 + github.com/gofrs/uuid v3.2.0+incompatible + github.com/gorilla/mux v1.7.3 + github.com/gorilla/websocket v1.4.1 + github.com/mitchellh/go-homedir v1.1.0 + github.com/pkg/errors v0.8.1 + github.com/sirupsen/logrus v1.4.2 + github.com/spf13/pflag v1.0.5 // indirect + gopkg.in/validator.v2 v2.0.0-20191107172027-c3144fdedc21 // indirect + gopkg.in/yaml.v2 v2.2.7 +) diff --git a/cmd/sii-editor/go.sum b/cmd/sii-editor/go.sum new file mode 100644 index 0000000..408c8d5 --- /dev/null +++ b/cmd/sii-editor/go.sum @@ -0,0 +1,62 @@ +github.com/Luzifer/go_helpers/v2 v2.9.1 h1:MVUOlD6tJ2m/iTF0hllnI/QVZH5kI+TikUm1WRGg/c4= +github.com/Luzifer/go_helpers/v2 v2.9.1/go.mod h1:ZnWxPjyCdQ4rZP3kNiMSUW/7FigU1X9Rz8XopdJ5ZCU= +github.com/Luzifer/rconfig v2.2.0+incompatible h1:Kle3+rshPM7LxciOheaR4EfHUzibkDDGws04sefQ5m8= +github.com/Luzifer/rconfig v2.2.0+incompatible/go.mod h1:9pet6z2+mm/UAB0jF/rf0s62USfHNolzgR6Q4KpsJI0= +github.com/Luzifer/rconfig/v2 v2.2.1 h1:zcDdLQlnlzwcBJ8E0WFzOkQE1pCMn3EbX0dFYkeTczg= +github.com/Luzifer/rconfig/v2 v2.2.1/go.mod h1:OKIX0/JRZrPJ/ZXXWklQEFXA6tBfWaljZbW37w+sqBw= +github.com/Luzifer/scs-extract v0.1.0 h1:f7fgtGZVgFiTZTWETjO92e43zzk8dXkL6zzWeRcI/IE= +github.com/Luzifer/scs-extract v0.1.0/go.mod h1:cns55Lfhfnx1eb4mQP2mdKQBWZmOaQYVbNqJrYIJwpU= +github.com/Luzifer/scs-extract v0.1.1-0.20191226001718-17f71578850d h1:KElMkqh5nijsan2uaw+gsdxu56NnVg/1N+Wbw3Jdy+o= +github.com/Luzifer/scs-extract v0.1.1-0.20191226001718-17f71578850d/go.mod h1:cns55Lfhfnx1eb4mQP2mdKQBWZmOaQYVbNqJrYIJwpU= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/leekchan/gtf v0.0.0-20190214083521-5fba33c5b00b/go.mod h1:thNruaSwydMhkQ8dXzapABF9Sc1Tz08ZBcDdgott9RA= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2 h1:uqH7bpe+ERSiDa34FDOF7RikN6RzXgduUF8yarlZp94= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/tenfyzhong/cityhash v0.0.0-20181130044406-4c2731b5918c/go.mod h1:Izvvi9mFtnF9nbPc2Z/gazIliNnYtxOsbQnFYpmxbfc= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc= +gopkg.in/validator.v2 v2.0.0-20191107172027-c3144fdedc21 h1:2QQcyaEBdpfjjYkF0MXc69jZbHb4IOYuXz2UwsmVM8k= +gopkg.in/validator.v2 v2.0.0-20191107172027-c3144fdedc21/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/cmd/sii-editor/locale.go b/cmd/sii-editor/locale.go new file mode 100644 index 0000000..45129c2 --- /dev/null +++ b/cmd/sii-editor/locale.go @@ -0,0 +1,72 @@ +package main + +import ( + "bytes" + "io" + "os" + "path" + + "github.com/Luzifer/scs-extract/scs" + "github.com/Luzifer/sii" + "github.com/Luzifer/sii/t3nk" + "github.com/pkg/errors" +) + +func getLocale(locale string) (*sii.LocalizationDB, error) { + fPath := path.Join(getGamePath(), "locale.scs") + + stat, err := os.Stat(fPath) + if err != nil { + return nil, errors.Wrap(err, "Unable to stat locale.scs archive") + } + + scsFile, err := os.Open(fPath) + if err != nil { + return nil, errors.Wrap(err, "Unable to open locale.scs archive") + } + defer scsFile.Close() + + r, err := scs.NewReader(scsFile, stat.Size()) + if err != nil { + return nil, errors.Wrap(err, "Unable to open locale.scs archive") + } + + var codedLocale = new(bytes.Buffer) + for _, f := range r.Files { + if f.Name == path.Join("locale", locale, "local.sii") { + lf, err := f.Open() + if err != nil { + return nil, errors.Wrap(err, "Unable to read local.sii file from archive") + } + defer lf.Close() + + // Using the reader directly somehow broke mid-file, pre-buffering works fine + if _, err := io.Copy(codedLocale, lf); err != nil { + return nil, errors.Wrap(err, "Unable to copy local.sii file from archive") + } + break + } + } + + if codedLocale.Len() == 0 { + return nil, errors.New("Found no locale information") + } + + localeReader, err := t3nk.Decode(codedLocale) + if err != nil { + return nil, errors.Wrap(err, "Unable to decode locale.sii") + } + + unit, err := sii.ParseSIIPlainFile(localeReader) + if err != nil { + return nil, errors.Wrap(err, "Unable to read unit from locale.sii") + } + + for _, b := range unit.BlocksByClass("localization_db") { + if v, ok := b.(*sii.LocalizationDB); ok { + return v, nil + } + } + + return nil, errors.New("No localization db found in locale") +} diff --git a/cmd/sii-editor/main.go b/cmd/sii-editor/main.go new file mode 100644 index 0000000..8f4a4c5 --- /dev/null +++ b/cmd/sii-editor/main.go @@ -0,0 +1,98 @@ +package main + +import ( + "encoding/hex" + "fmt" + "net/http" + "os" + + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" + + "github.com/Luzifer/rconfig/v2" + "github.com/Luzifer/sii" +) + +const defaultTranslation = "en_us" + +var ( + cfg = struct { + Config string `flag:"config,c" vardefault:"config" description:"Optional configuration file"` + DecryptKey string `flag:"decrypt-key" default:"" description:"Hex formated decryption key" validate:"nonzero"` + Game string `flag:"game,g" default:"ets2" description:"Which game to manage (ets2 / ats)"` + Listen string `flag:"listen" default:":3000" description:"Port/IP to listen on"` + LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"` + VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"` + }{} + + baseGameUnit *sii.Unit + locale *sii.LocalizationDB + router = mux.NewRouter() + userConfig *configFile + + version = "dev" +) + +func init() { + rconfig.SetVariableDefaults(map[string]string{ + "config": userConfigPath, + }) + + rconfig.AutoEnv(true) + if err := rconfig.ParseAndValidate(&cfg); err != nil { + log.Fatalf("Unable to parse commandline options: %s", err) + } + + if cfg.VersionAndExit { + fmt.Printf("sii-editor %s\n", version) + os.Exit(0) + } + + if l, err := log.ParseLevel(cfg.LogLevel); err != nil { + log.WithError(err).Fatal("Unable to parse log level") + } else { + log.SetLevel(l) + } +} + +func main() { + decryptKey, err := hex.DecodeString(cfg.DecryptKey) + if err != nil { + log.WithError(err).Fatal("Unable to read encryption key") + } + + sii.SetEncryptionKey(decryptKey) + + if userConfig, err = loadUserConfig(cfg.Config); err != nil && err != errUserConfigNotFound { + log.WithError(err).Fatal("Unable to load user config") + } + + if err = userConfig.loadDefaults(); err != nil { + log.WithError(err).Fatal("Unable to load missing defaults for user config") + } + + log.Info("Loading game base data...") + + if baseGameUnit, err = readBaseData(); err != nil { + log.WithError(err).Fatal("Unable to load game definitions") + } + + log.WithFields(log.Fields{ + "cargos": len(baseGameUnit.BlocksByClass("cargo_data")), + "cities": len(baseGameUnit.BlocksByClass("city_data")), + "companies": len(baseGameUnit.BlocksByClass("company_permanent")), + }).Info("Game base data loaded") + + log.Info("Loading translations...") + // TODO: Make user definable + if locale, err = getLocale(defaultTranslation); err != nil { + log.WithError(err).Fatal("Unable to load translations") + } + log.WithField("translations", len(locale.Keys)).Info("Translations loaded") + + log.WithField("addr", cfg.Listen).Info("Starting API server...") + + if err := http.ListenAndServe(cfg.Listen, router); err != nil { + log.WithError(err).Fatal("HTTP server caused an error") + } +} diff --git a/cmd/sii-editor/paths.go b/cmd/sii-editor/paths.go new file mode 100644 index 0000000..dd49cd8 --- /dev/null +++ b/cmd/sii-editor/paths.go @@ -0,0 +1,49 @@ +package main + +import ( + "path" + + "github.com/mitchellh/go-homedir" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +const ( + pathETS2 = "ets2" + pathATS = "ats" +) + +var errPathNotFound = errors.New("Could not find path") + +func expandHomedir(dir string) string { + s, err := homedir.Expand(dir) + if err != nil { + log.WithError(err).Error("Unable to expand home path") + return dir + } + return s +} + +func getGamePath() string { + return expandHomedir(userConfig.GameDirectories[cfg.Game]) +} + +func getProfilesPath() string { + return expandHomedir(userConfig.ProfileDirectories[cfg.Game]) +} + +func getProfilePath(profile string) string { + return path.Join(getProfilesPath(), profile) +} + +func getProfileInfoPath(profile string) string { + return path.Join(getProfilePath(profile), "profile.sii") +} + +func getSavePath(profile, save string) string { + return path.Join(getProfilePath(profile), "save", save) +} + +func getSaveFilePath(profile, save, file string) string { + return path.Join(getSavePath(profile, save), file) +} diff --git a/cmd/sii-editor/paths_linux.go b/cmd/sii-editor/paths_linux.go new file mode 100644 index 0000000..aa545ee --- /dev/null +++ b/cmd/sii-editor/paths_linux.go @@ -0,0 +1,15 @@ +package main + +var userConfigPath = "~/.config/sii-editor/config.yml" + +var profilePaths = map[string]string{ + // Linux default for non-Steam-profiles + pathATS: "~/.local/share/American Truck Simulator/profiles", + pathETS2: "~/.local/share/Euro Truck Simulator 2/profiles", +} + +var gamePaths = map[string]string{ + // Linux default installation path for Steam games + pathATS: "~/.local/share/Steam/steamapps/common/American Truck Simulator", + pathETS2: "~/.local/share/Steam/steamapps/common/Euro Truck Simulator 2", +} diff --git a/cmd/sii-editor/paths_windows.go b/cmd/sii-editor/paths_windows.go new file mode 100644 index 0000000..b6d8677 --- /dev/null +++ b/cmd/sii-editor/paths_windows.go @@ -0,0 +1,15 @@ +package main + +var userConfigPath = `~\documents\sii-editor\config.yml` + +var profilePaths = map[string]string{ + // Windows default for non-Steam-profiles + pathATS: `~\documents\American Truck Simulator\profiles`, + pathETS2: `~\documents\Euro Truck Simulator 2\profiles`, +} + +var gamePaths = map[string]string{ + // Windows default installation path for Steam games + pathATS: `C:\Program Files (x86)\Steam\steamapps\common\American Truck Simulator`, + pathETS2: `C:\Program Files (x86)\Steam\steamapps\common\Euro Truck Simulator 2`, +} diff --git a/cmd/sii-editor/profiles.go b/cmd/sii-editor/profiles.go new file mode 100644 index 0000000..6a0c9b8 --- /dev/null +++ b/cmd/sii-editor/profiles.go @@ -0,0 +1,123 @@ +package main + +import ( + "io/ioutil" + "os" + "path" + "time" + + "github.com/Luzifer/sii" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +type commSaveInfo struct { + Name string `json:"name"` + GameTime int64 `json:"game_time"` + SaveTime time.Time `json:"save_time"` +} + +func commSaveInfoFromSaveContainer(c *sii.SaveContainer) commSaveInfo { + var out commSaveInfo + + out.Name = c.SaveName + out.GameTime = c.Time + out.SaveTime = time.Unix(c.FileTime, 0) + + return out +} + +type commProfileInfo struct { + CompanyName string `json:"company_name"` + ProfileName string `json:"profile_name"` + CreationTime time.Time `json:"creation_time"` + SaveTime time.Time `json:"save_time"` +} + +func commProfileInfoFromUserProfile(p *sii.UserProfile) commProfileInfo { + var out commProfileInfo + + out.CompanyName = p.CompanyName + out.ProfileName = p.ProfileName + out.CreationTime = time.Unix(p.CreationTime, 0) + out.SaveTime = time.Unix(p.SaveTime, 0) + + return out +} + +func listSaves(profile string) (map[string]commSaveInfo, error) { + entries, err := ioutil.ReadDir(path.Join(getProfilePath(profile), "save")) + if err != nil { + return nil, errors.Wrap(err, "Unable to list saves") + } + + var out = map[string]commSaveInfo{} + + for _, entry := range entries { + if !entry.IsDir() { + // There shouldn't be files but whatever + continue + } + + var sFile = getSaveFilePath(profile, entry.Name(), "info.sii") + if _, err := os.Stat(sFile); err != nil { + // That directory contains no profile.sii - Weird but okay + log.WithFields(log.Fields{ + "profile": profile, + "save": entry.Name(), + }).Debug("Found save directory without info.sii") + continue + } + + unit, err := sii.ReadUnitFile(sFile) + if err != nil { + return nil, errors.Wrapf(err, "Unable to read unit for save %q", entry.Name()) + } + + for _, b := range unit.Entries { + if v, ok := b.(*sii.SaveContainer); ok { + out[entry.Name()] = commSaveInfoFromSaveContainer(v) + break + } + } + } + + return out, nil +} + +func listProfiles() (map[string]commProfileInfo, error) { + entries, err := ioutil.ReadDir(getProfilesPath()) + if err != nil { + return nil, errors.Wrap(err, "Unable to list profiles") + } + + var out = map[string]commProfileInfo{} + + for _, entry := range entries { + if !entry.IsDir() { + // There shouldn't be files but whatever + continue + } + + var pFile = getProfileInfoPath(entry.Name()) + if _, err := os.Stat(pFile); err != nil { + // That directory contains no profile.sii - Weird but okay + log.WithField("profile", entry.Name()).Debug("Found profile directory without profile.sii") + continue + } + + unit, err := sii.ReadUnitFile(pFile) + if err != nil { + return nil, errors.Wrapf(err, "Unable to read unit for profile %q", entry.Name()) + } + + for _, b := range unit.Entries { + if v, ok := b.(*sii.UserProfile); ok { + out[entry.Name()] = commProfileInfoFromUserProfile(v) + break + } + } + } + + return out, nil +} diff --git a/cmd/sii-editor/saves.go b/cmd/sii-editor/saves.go new file mode 100644 index 0000000..f85e413 --- /dev/null +++ b/cmd/sii-editor/saves.go @@ -0,0 +1,246 @@ +package main + +import ( + "os" + "path" + + "github.com/Luzifer/sii" + "github.com/pkg/errors" +) + +const ( + fixAll = "all" + fixCargoOnly = "cargo" + fixTrailerOnly = "trailer" + fixTruckOnly = "truck" +) + +type commSaveDetails struct { + GameTime int64 `json:"game_time"` + + ExperiencePoints int64 `json:"experience_points"` + Money int64 `json:"money"` + + CargoDamage float32 `json:"cargo_damage"` + TruckWear float32 `json:"truck_wear"` + TrailerAttached bool `json:"trailer_attached"` + TrailerWear float32 `json:"trailer_wear"` + + AttachedTrailer string `json:"attached_trailer"` + OwnedTrailers map[string]string `json:"owned_trailers"` + + CurrentJob *commSaveJob `json:"current_job"` +} + +type commSaveJob struct { + OriginReference string `json:"origin_reference"` + TargetReference string `json:"target_reference"` + CargoReference string `json:"cargo_reference"` + TrailerReference string `json:"trailer_reference"` + Distance int64 `json:"distance"` + Urgency *int64 `json:"urgency,omitempty"` + Weight float32 `json:"weight,omitempty"` + Expires int64 `json:"expires,omitempty"` +} + +func commSaveDetailsFromUnit(unit *sii.Unit) (out commSaveDetails, err error) { + var economy *sii.Economy + + for _, b := range unit.BlocksByClass("economy") { + if v, ok := b.(*sii.Economy); ok { + economy = v + } + } + + if economy == nil { + return out, errors.New("Found no economy object") + } + + var ( + bank *sii.Bank + job *sii.PlayerJob + player *sii.Player + truck *sii.Vehicle + trailer *sii.Trailer + ) + + bank = economy.Bank.Resolve().(*sii.Bank) + player = economy.Player.Resolve().(*sii.Player) + + out.ExperiencePoints = economy.ExperiencePoints + out.GameTime = economy.GameTime + out.Money = bank.MoneyAccount + out.TrailerAttached = player.AssignedTrailerConnected + + if v, ok := player.CurrentJob.Resolve().(*sii.PlayerJob); ok { + job = v + } + + if v, ok := player.AssignedTruck.Resolve().(*sii.Vehicle); ok { + truck = v + } + + if v, ok := player.AssignedTrailer.Resolve().(*sii.Trailer); ok { + out.AttachedTrailer = player.AssignedTrailer.Target + trailer = v + } + + if truck != nil { + for _, pb := range truck.Accessories { + var wear float32 + if v, ok := pb.Resolve().(*sii.VehicleAccessory); ok { + wear = v.Wear + } + + if v, ok := pb.Resolve().(*sii.VehicleWheelAccessory); ok { + wear = v.Wear + } + + if wear > out.TruckWear { + out.TruckWear = wear + } + } + } + + if len(player.Trailers) > 0 { + out.OwnedTrailers = map[string]string{} + for _, tp := range player.Trailers { + if t, ok := tp.Resolve().(*sii.Trailer); ok { + out.OwnedTrailers[t.Name()] = t.CleanedLicensePlate() + } + } + } + + if trailer != nil { + for _, pb := range trailer.Accessories { + var wear float32 + if v, ok := pb.Resolve().(*sii.VehicleAccessory); ok { + wear = v.Wear + } + + if v, ok := pb.Resolve().(*sii.VehicleWheelAccessory); ok { + wear = v.Wear + } + + if wear > out.TrailerWear { + out.TrailerWear = wear + } + } + + out.CargoDamage = trailer.CargoDamage + } + + if job != nil { + out.CurrentJob = &commSaveJob{ + OriginReference: job.SourceCompany.Target, + TargetReference: job.TargetCompany.Target, + CargoReference: job.Cargo.Target, + TrailerReference: job.CompanyTrailer.Target, + Distance: job.PlannedDistanceKM, + Urgency: job.Urgency, + } + } + + return out, nil +} + +func fixPlayerTruck(unit *sii.Unit, fixType string) error { + // In call cases we need the player as the starting point + var player *sii.Player + + for _, b := range unit.Entries { + if v, ok := b.(*sii.Player); ok { + player = v + break + } + } + + if player == nil { + return errors.New("Found no player object") + } + + // Fix truck + if fixType == fixAll || fixType == fixTruckOnly { + truck := player.AssignedTruck.Resolve().(*sii.Vehicle) + for _, pb := range truck.Accessories { + if v, ok := pb.Resolve().(*sii.VehicleAccessory); ok { + v.Wear = 0 + } + + if v, ok := pb.Resolve().(*sii.VehicleWheelAccessory); ok { + v.Wear = 0 + } + } + } + + // Fix trailer + if (fixType == fixAll || fixType == fixTrailerOnly) && player.AssignedTrailer.Resolve() != nil { + trailer := player.AssignedTrailer.Resolve().(*sii.Trailer) + for _, pb := range trailer.Accessories { + if v, ok := pb.Resolve().(*sii.VehicleAccessory); ok { + v.Wear = 0 + } + + if v, ok := pb.Resolve().(*sii.VehicleWheelAccessory); ok { + v.Wear = 0 + } + } + } + + // Fix cargo + if (fixType == fixAll || fixType == fixCargoOnly) && player.AssignedTrailer.Resolve() != nil { + trailer := player.AssignedTrailer.Resolve().(*sii.Trailer) + trailer.CargoDamage = 0 + } + + return nil +} + +// loadSave reads game- and info- unit and returns them unmodified +func loadSave(profile, save string) (*sii.Unit, *sii.SaveContainer, error) { + var ( + saveInfoPath = getSaveFilePath(profile, save, "info.sii") + saveUnitPath = getSaveFilePath(profile, save, "game.sii") + ) + + infoUnit, err := sii.ReadUnitFile(saveInfoPath) + if err != nil { + return nil, nil, errors.Wrap(err, "Unable to load save-info") + } + + var info *sii.SaveContainer + for _, b := range infoUnit.Entries { + if v, ok := b.(*sii.SaveContainer); ok { + info = v + } + } + + gameUnit, err := sii.ReadUnitFile(saveUnitPath) + if err != nil { + return nil, info, errors.Wrap(err, "Unable to load save-unit") + } + + return gameUnit, info, nil +} + +// storeSave writes game- and info- unit without checking for existance! +func storeSave(profile, save string, unit *sii.Unit, info *sii.SaveContainer) error { + var ( + saveInfoPath = getSaveFilePath(profile, save, "info.sii") + saveUnitPath = getSaveFilePath(profile, save, "game.sii") + ) + + if err := os.MkdirAll(path.Dir(saveInfoPath), 0700); err != nil { + return errors.Wrap(err, "Unable to create save-dir") + } + + if err := sii.WriteUnitFile(saveInfoPath, &sii.Unit{Entries: []sii.Block{info}}); err != nil { + return errors.Wrap(err, "Unable to write info unit") + } + + if err := sii.WriteUnitFile(saveUnitPath, unit); err != nil { + return errors.Wrap(err, "Unable to write game unit") + } + + return nil +} diff --git a/t3nk/3nk.go b/t3nk/3nk.go new file mode 100644 index 0000000..e44c312 --- /dev/null +++ b/t3nk/3nk.go @@ -0,0 +1,118 @@ +package t3nk + +import ( + "bytes" + "encoding/binary" + "io" + "math/rand" + "time" + + "github.com/pkg/errors" +) + +var ( + // Key table entries were calculated from this formula: + // Key[i] = (((i shl 2) xor not i) shl 3) xor i + // + // Or simpler: Stole it from https://github.com/idma88/3nK_Transcode/blob/master/Source/SII_3nK_Transcoder.pas + keyTable = []byte{ + 0xf8, 0xd1, 0xaa, 0x83, 0x5c, 0x75, 0x0e, 0x27, 0xb0, 0x99, 0xe2, 0xcb, 0x14, 0x3d, 0x46, 0x6f, + 0x68, 0x41, 0x3a, 0x13, 0xcc, 0xe5, 0x9e, 0xb7, 0x20, 0x09, 0x72, 0x5b, 0x84, 0xad, 0xd6, 0xff, + 0xd8, 0xf1, 0x8a, 0xa3, 0x7c, 0x55, 0x2e, 0x07, 0x90, 0xb9, 0xc2, 0xeb, 0x34, 0x1d, 0x66, 0x4f, + 0x48, 0x61, 0x1a, 0x33, 0xec, 0xc5, 0xbe, 0x97, 0x00, 0x29, 0x52, 0x7b, 0xa4, 0x8d, 0xf6, 0xdf, + 0xb8, 0x91, 0xea, 0xc3, 0x1c, 0x35, 0x4e, 0x67, 0xf0, 0xd9, 0xa2, 0x8b, 0x54, 0x7d, 0x06, 0x2f, + 0x28, 0x01, 0x7a, 0x53, 0x8c, 0xa5, 0xde, 0xf7, 0x60, 0x49, 0x32, 0x1b, 0xc4, 0xed, 0x96, 0xbf, + 0x98, 0xb1, 0xca, 0xe3, 0x3c, 0x15, 0x6e, 0x47, 0xd0, 0xf9, 0x82, 0xab, 0x74, 0x5d, 0x26, 0x0f, + 0x08, 0x21, 0x5a, 0x73, 0xac, 0x85, 0xfe, 0xd7, 0x40, 0x69, 0x12, 0x3b, 0xe4, 0xcd, 0xb6, 0x9f, + 0x78, 0x51, 0x2a, 0x03, 0xdc, 0xf5, 0x8e, 0xa7, 0x30, 0x19, 0x62, 0x4b, 0x94, 0xbd, 0xc6, 0xef, + 0xe8, 0xc1, 0xba, 0x93, 0x4c, 0x65, 0x1e, 0x37, 0xa0, 0x89, 0xf2, 0xdb, 0x04, 0x2d, 0x56, 0x7f, + 0x58, 0x71, 0x0a, 0x23, 0xfc, 0xd5, 0xae, 0x87, 0x10, 0x39, 0x42, 0x6b, 0xb4, 0x9d, 0xe6, 0xcf, + 0xc8, 0xe1, 0x9a, 0xb3, 0x6c, 0x45, 0x3e, 0x17, 0x80, 0xa9, 0xd2, 0xfb, 0x24, 0x0d, 0x76, 0x5f, + 0x38, 0x11, 0x6a, 0x43, 0x9c, 0xb5, 0xce, 0xe7, 0x70, 0x59, 0x22, 0x0b, 0xd4, 0xfd, 0x86, 0xaf, + 0xa8, 0x81, 0xfa, 0xd3, 0x0c, 0x25, 0x5e, 0x77, 0xe0, 0xc9, 0xb2, 0x9b, 0x44, 0x6d, 0x16, 0x3f, + 0x18, 0x31, 0x4a, 0x63, 0xbc, 0x95, 0xee, 0xc7, 0x50, 0x79, 0x02, 0x2b, 0xf4, 0xdd, 0xa6, 0x8f, + 0x88, 0xa1, 0xda, 0xf3, 0x2c, 0x05, 0x7e, 0x57, 0xc0, 0xe9, 0x92, 0xbb, 0x64, 0x4d, 0x36, 0x1f, + } + sii3nKSignature = uint32(0x014B6E33) // 3nK#01 +) + +type sii3nKHeader struct { + Signature uint32 + UnkByte uint8 + Seed uint8 +} + +func Decode(r io.Reader) (io.Reader, error) { + var hdr sii3nKHeader + + if err := binary.Read(r, binary.LittleEndian, &hdr); err != nil { + return nil, errors.Wrap(err, "Unable to read header") + } + + if hdr.Signature != sii3nKSignature { + return nil, errors.Errorf("Unexpected file signature: %d (expected %d)", hdr.Signature, sii3nKSignature) + } + + var ( + buf = new(bytes.Buffer) + tBuf = make([]byte, 16*1024) // Buffer size = 16K + ) + + for { + n, err := r.Read(tBuf) + if n > 0 { + buf.Write(transcode(tBuf, n, hdr.Seed)[:n]) + } + if err != nil { + if err == io.EOF { + break + } + return nil, errors.Wrap(err, "Unable to read from input") + } + } + + return buf, nil +} + +func Encode(r io.Reader) (io.Reader, error) { + rand.Seed(time.Now().UnixNano()) + + var hdr = sii3nKHeader{ + Signature: sii3nKSignature, + Seed: uint8(rand.Intn(256)), + } + + var ( + buf = new(bytes.Buffer) + tBuf = make([]byte, 16*1024) // Buffer size = 16K + ) + + if err := binary.Write(buf, binary.LittleEndian, hdr); err != nil { + return nil, errors.Wrap(err, "Unable to write header") + } + + for { + n, err := r.Read(tBuf) + if n > 0 { + buf.Write(transcode(tBuf, n, hdr.Seed)[:n]) + } + if err != nil { + if err == io.EOF { + break + } + return nil, errors.Wrap(err, "Unable to read from input") + } + } + + return buf, nil +} + +func transcode(in []byte, size int, seed uint8) []byte { + var out = make([]byte, len(in)) + + for i := 0; i < size; i++ { + out[i] = in[i] ^ keyTable[byte(seed+uint8(i))] + } + + return out +} diff --git a/t3nk/3nk_test.go b/t3nk/3nk_test.go new file mode 100644 index 0000000..4a50607 --- /dev/null +++ b/t3nk/3nk_test.go @@ -0,0 +1,31 @@ +package t3nk + +import ( + "io/ioutil" + "strings" + "testing" +) + +func TestEncodeToDecode(t *testing.T) { + expect := "Ohai!" + f := strings.NewReader(expect) + + r, err := Encode(f) + if err != nil { + t.Fatalf("Unable to encode test string: %s", err) + } + + dr, err := Decode(r) + if err != nil { + t.Fatalf("Unable to decode test string: %s", err) + } + + raw, err := ioutil.ReadAll(dr) + if err != nil { + t.Fatalf("Unable to read decoded test string: %s", err) + } + + if s := string(raw); s != expect { + t.Errorf("Did not receive expected string: exp=%q got=%q", expect, s) + } +} diff --git a/t3nk/go.mod b/t3nk/go.mod new file mode 100644 index 0000000..077c3d7 --- /dev/null +++ b/t3nk/go.mod @@ -0,0 +1,5 @@ +module github.com/Luzifer/sii/t3nk + +go 1.13 + +require github.com/pkg/errors v0.8.1 diff --git a/t3nk/go.sum b/t3nk/go.sum new file mode 100644 index 0000000..f29ab35 --- /dev/null +++ b/t3nk/go.sum @@ -0,0 +1,2 @@ +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=