513 lines
12 KiB
Go
513 lines
12 KiB
Go
package project
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
const ConfigFile = "nwn-tool.json"
|
|
|
|
var SourceExtensions = []string{
|
|
".are", ".dlg", ".fac", ".git", ".ifo", ".jrl",
|
|
".utc", ".utd", ".ute", ".uti", ".utm", ".utp", ".uts", ".utt", ".utw",
|
|
}
|
|
|
|
var AssetExtensions = []string{
|
|
".2da", ".bik", ".bmp", ".dds", ".dwk", ".gr2", ".itp", ".jpg", ".lod", ".lyt", ".mdb", ".mdl", ".mdx", ".mtr", ".plt", ".png", ".pwk", ".set", ".shd", ".tga", ".txi", ".uti", ".vis", ".wav", ".wlk", ".wok", ".xml",
|
|
}
|
|
|
|
type Project struct {
|
|
Root string
|
|
Config Config
|
|
Inventory Inventory
|
|
}
|
|
|
|
type Config struct {
|
|
Module ModuleConfig `json:"module"`
|
|
Paths PathConfig `json:"paths"`
|
|
HAKs []HAKConfig `json:"haks"`
|
|
TopData TopDataConfig `json:"topdata"`
|
|
Extract ExtractConfig `json:"extract"`
|
|
}
|
|
|
|
type ModuleConfig struct {
|
|
Name string `json:"name"`
|
|
ResRef string `json:"resref"`
|
|
Description string `json:"description"`
|
|
HAKOrder []string `json:"hak_order"`
|
|
}
|
|
|
|
type PathConfig struct {
|
|
Source string `json:"source"`
|
|
Assets string `json:"assets"`
|
|
Build string `json:"build"`
|
|
}
|
|
|
|
type HAKConfig struct {
|
|
Name string `json:"name"`
|
|
Priority int `json:"priority"`
|
|
MaxBytes int64 `json:"max_bytes"`
|
|
Split bool `json:"split"`
|
|
Optional bool `json:"optional,omitempty"`
|
|
Include []string `json:"include"`
|
|
}
|
|
|
|
type TopDataConfig struct {
|
|
Source string `json:"source"`
|
|
Build string `json:"build"`
|
|
ReferenceBuilder string `json:"reference_builder"`
|
|
Assets string `json:"assets"`
|
|
PackageHAK string `json:"package_hak"`
|
|
PackageTLK string `json:"package_tlk"`
|
|
}
|
|
|
|
type ExtractConfig struct {
|
|
IgnoreExtensions []string `json:"ignore_extensions"`
|
|
DeleteModuleArchiveAfterSuccess bool `json:"delete_module_archive_after_success,omitempty"`
|
|
}
|
|
|
|
type Inventory struct {
|
|
SourceFiles []string
|
|
ScriptFiles []string
|
|
AssetFiles []string
|
|
Extensions []string
|
|
}
|
|
|
|
type InventoryReport struct {
|
|
SourceFiles int
|
|
ScriptFiles int
|
|
AssetFiles int
|
|
Extensions []string
|
|
}
|
|
|
|
func FindRoot(start string) (string, error) {
|
|
current := start
|
|
for {
|
|
candidate := filepath.Join(current, ConfigFile)
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return current, nil
|
|
}
|
|
parent := filepath.Dir(current)
|
|
if parent == current {
|
|
return "", fmt.Errorf("could not find %s from %s", ConfigFile, start)
|
|
}
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
func Load(root string) (*Project, error) {
|
|
raw, err := os.ReadFile(filepath.Join(root, ConfigFile))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", ConfigFile, err)
|
|
}
|
|
|
|
cfg := defaultConfig()
|
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", ConfigFile, err)
|
|
}
|
|
|
|
return &Project{
|
|
Root: root,
|
|
Config: cfg,
|
|
}, nil
|
|
}
|
|
|
|
func (p *Project) ValidateLayout() error {
|
|
var failures []error
|
|
|
|
if strings.TrimSpace(p.Config.Module.Name) == "" {
|
|
failures = append(failures, errors.New("module.name is required"))
|
|
}
|
|
if strings.TrimSpace(p.Config.Module.ResRef) == "" {
|
|
failures = append(failures, errors.New("module.resref is required"))
|
|
}
|
|
if len(p.Config.Module.ResRef) > 16 {
|
|
failures = append(failures, fmt.Errorf("module.resref %q exceeds 16 characters", p.Config.Module.ResRef))
|
|
}
|
|
if p.HasTopData() {
|
|
if err := validateOutputFileName("topdata.package_hak", p.TopDataPackageHAKName(), ".hak"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName("topdata.package_tlk", p.TopDataPackageTLKName(), ".tlk"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName("topdata.compiled_tlk", filepath.Base(p.TopDataCompiledTLKPath()), ".tlk"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
}
|
|
|
|
for index, hak := range p.Config.HAKs {
|
|
if strings.TrimSpace(hak.Name) == "" {
|
|
failures = append(failures, fmt.Errorf("haks[%d].name is required", index))
|
|
}
|
|
if hak.Priority <= 0 {
|
|
failures = append(failures, fmt.Errorf("haks[%d].priority must be greater than zero", index))
|
|
}
|
|
if hak.MaxBytes < 0 {
|
|
failures = append(failures, fmt.Errorf("haks[%d].max_bytes must be zero or greater", index))
|
|
}
|
|
if len(hak.Include) == 0 {
|
|
failures = append(failures, fmt.Errorf("haks[%d].include must have at least one pattern", index))
|
|
}
|
|
}
|
|
|
|
for label, pathFn := range map[string]func() string{
|
|
"paths.build": p.BuildDir,
|
|
} {
|
|
path := pathFn()
|
|
if path == "" || filepath.Clean(path) == p.Root {
|
|
continue
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
failures = append(failures, fmt.Errorf("%s: %w", label, err))
|
|
continue
|
|
}
|
|
if !info.IsDir() {
|
|
failures = append(failures, fmt.Errorf("%s must be a directory: %s", label, path))
|
|
}
|
|
}
|
|
|
|
if len(failures) > 0 {
|
|
return errors.Join(failures...)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *Project) Scan() error {
|
|
var sourceFiles []string
|
|
var sourceExts []string
|
|
|
|
sourceDir := p.SourceDir()
|
|
if sourceDir != "" && filepath.Clean(sourceDir) != p.Root {
|
|
var err error
|
|
sourceFiles, sourceExts, err = scanDir(sourceDir, func(path string) bool {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
return ext == ".json" || ext == ".nss"
|
|
})
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("scan source tree: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
assetFiles, _, err := scanDir(p.AssetsDir(), func(path string) bool {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
return slices.Contains(AssetExtensions, ext)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
assetFiles = nil
|
|
} else {
|
|
return fmt.Errorf("scan assets tree: %w", err)
|
|
}
|
|
}
|
|
|
|
var scripts []string
|
|
var sources []string
|
|
extensionSet := map[string]struct{}{}
|
|
for _, ext := range sourceExts {
|
|
extensionSet[ext] = struct{}{}
|
|
}
|
|
|
|
for _, path := range sourceFiles {
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".nss":
|
|
scripts = append(scripts, path)
|
|
case ".json":
|
|
sources = append(sources, path)
|
|
}
|
|
}
|
|
|
|
extensions := make([]string, 0, len(extensionSet))
|
|
for ext := range extensionSet {
|
|
extensions = append(extensions, ext)
|
|
}
|
|
slices.Sort(extensions)
|
|
|
|
p.Inventory = Inventory{
|
|
SourceFiles: sources,
|
|
ScriptFiles: scripts,
|
|
AssetFiles: assetFiles,
|
|
Extensions: extensions,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Project) SourceDir() string {
|
|
return filepath.Join(p.Root, p.Config.Paths.Source)
|
|
}
|
|
|
|
func (p *Project) AssetsDir() string {
|
|
assets := strings.TrimSpace(p.Config.Paths.Assets)
|
|
if assets == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(p.Root, assets)
|
|
}
|
|
|
|
func (p *Project) BuildDir() string {
|
|
return filepath.Join(p.Root, p.Config.Paths.Build)
|
|
}
|
|
|
|
func (p *Project) ModuleArchivePath() string {
|
|
return filepath.Join(p.BuildDir(), p.Config.Module.ResRef+".mod")
|
|
}
|
|
|
|
func (p *Project) HAKManifestPath() string {
|
|
return filepath.Join(p.BuildDir(), "haks.json")
|
|
}
|
|
|
|
func (p *Project) HAKArchivePath(name string) string {
|
|
return filepath.Join(p.BuildDir(), strings.TrimSpace(name)+".hak")
|
|
}
|
|
|
|
func (p *Project) CloneWithHAKNames(names []string) (*Project, error) {
|
|
if len(names) == 0 {
|
|
return p, nil
|
|
}
|
|
|
|
wanted := make(map[string]struct{}, len(names))
|
|
for _, name := range names {
|
|
trimmed := strings.TrimSpace(strings.ToLower(name))
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
wanted[trimmed] = struct{}{}
|
|
}
|
|
if len(wanted) == 0 {
|
|
return p, nil
|
|
}
|
|
|
|
filtered := make([]HAKConfig, 0, len(p.Config.HAKs))
|
|
found := make(map[string]struct{}, len(wanted))
|
|
for _, hak := range p.Config.HAKs {
|
|
key := strings.ToLower(strings.TrimSpace(hak.Name))
|
|
if _, ok := wanted[key]; !ok {
|
|
continue
|
|
}
|
|
filtered = append(filtered, hak)
|
|
found[key] = struct{}{}
|
|
}
|
|
|
|
var missing []string
|
|
for _, name := range names {
|
|
key := strings.TrimSpace(strings.ToLower(name))
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, ok := found[key]; !ok {
|
|
missing = append(missing, name)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
return nil, fmt.Errorf("unknown hak name(s): %s", strings.Join(missing, ", "))
|
|
}
|
|
|
|
clone := *p
|
|
clone.Config = p.Config
|
|
clone.Config.HAKs = filtered
|
|
clone.Inventory = p.Inventory
|
|
return &clone, nil
|
|
}
|
|
|
|
func (p *Project) HasTopData() bool {
|
|
return strings.TrimSpace(p.Config.TopData.Source) != ""
|
|
}
|
|
|
|
func (p *Project) TopDataSourceDir() string {
|
|
if !p.HasTopData() {
|
|
return ""
|
|
}
|
|
return filepath.Join(p.Root, p.Config.TopData.Source)
|
|
}
|
|
|
|
func (p *Project) TopDataBuildDir() string {
|
|
buildPath := strings.TrimSpace(p.Config.TopData.Build)
|
|
if buildPath == "" {
|
|
buildPath = filepath.Join(p.Config.Paths.Build, "topdata")
|
|
}
|
|
if filepath.IsAbs(buildPath) {
|
|
return buildPath
|
|
}
|
|
return filepath.Join(p.Root, buildPath)
|
|
}
|
|
|
|
func (p *Project) TopDataReferenceBuilderDir() string {
|
|
ref := strings.TrimSpace(p.Config.TopData.ReferenceBuilder)
|
|
if ref == "" {
|
|
return ""
|
|
}
|
|
if filepath.IsAbs(ref) {
|
|
return ref
|
|
}
|
|
return filepath.Join(p.Root, ref)
|
|
}
|
|
|
|
func (p *Project) TopDataAssetsDir() string {
|
|
assets := strings.TrimSpace(p.Config.TopData.Assets)
|
|
if assets == "" {
|
|
return ""
|
|
}
|
|
if filepath.IsAbs(assets) {
|
|
return assets
|
|
}
|
|
absPath := filepath.Join(p.Root, assets)
|
|
if _, err := os.Stat(absPath); err == nil {
|
|
return absPath
|
|
}
|
|
return absPath
|
|
}
|
|
|
|
func (p *Project) TopDataUsesRepoCache() bool {
|
|
return sameCleanPath(p.TopDataBuildDir(), filepath.Join(p.Root, ".cache"))
|
|
}
|
|
|
|
func (p *Project) TopDataCompiled2DADir() string {
|
|
return filepath.Join(p.TopDataBuildDir(), "2da")
|
|
}
|
|
|
|
func (p *Project) TopDataCompiledTLKPath() string {
|
|
if p.TopDataUsesRepoCache() {
|
|
return filepath.Join(p.BuildDir(), "sow_tlk.tlk")
|
|
}
|
|
return filepath.Join(p.TopDataBuildDir(), "sow_tlk.tlk")
|
|
}
|
|
|
|
func (p *Project) TopDataCompiledTLKDir() string {
|
|
return filepath.Dir(p.TopDataCompiledTLKPath())
|
|
}
|
|
|
|
func (p *Project) TopDataWikiRootDir() string {
|
|
if p.TopDataUsesRepoCache() {
|
|
return filepath.Join(p.Root, ".cache", "wiki")
|
|
}
|
|
return filepath.Join(p.TopDataBuildDir(), "wiki")
|
|
}
|
|
|
|
func (p *Project) TopDataWikiPagesDir() string {
|
|
return filepath.Join(p.TopDataWikiRootDir(), "pages")
|
|
}
|
|
|
|
func (p *Project) TopDataWikiStatePath() string {
|
|
return filepath.Join(p.TopDataWikiRootDir(), "state.json")
|
|
}
|
|
|
|
func (p *Project) TopDataPackageHAKName() string {
|
|
name := strings.TrimSpace(p.Config.TopData.PackageHAK)
|
|
if name == "" {
|
|
name = "sow_top.hak"
|
|
}
|
|
return name
|
|
}
|
|
|
|
func (p *Project) TopDataPackageTLKName() string {
|
|
name := strings.TrimSpace(p.Config.TopData.PackageTLK)
|
|
if name == "" {
|
|
name = "sow_tlk.tlk"
|
|
}
|
|
return name
|
|
}
|
|
|
|
func (p *Project) TopDataPackageHAKPath() string {
|
|
return filepath.Join(p.BuildDir(), p.TopDataPackageHAKName())
|
|
}
|
|
|
|
func (p *Project) TopDataPackageTLKPath() string {
|
|
return filepath.Join(p.BuildDir(), p.TopDataPackageTLKName())
|
|
}
|
|
|
|
func (i Inventory) Report() InventoryReport {
|
|
return InventoryReport{
|
|
SourceFiles: len(i.SourceFiles),
|
|
ScriptFiles: len(i.ScriptFiles),
|
|
AssetFiles: len(i.AssetFiles),
|
|
Extensions: i.Extensions,
|
|
}
|
|
}
|
|
|
|
func defaultConfig() Config {
|
|
return Config{
|
|
Paths: PathConfig{
|
|
Build: "build",
|
|
},
|
|
TopData: TopDataConfig{
|
|
Build: ".cache",
|
|
PackageHAK: "sow_top.hak",
|
|
PackageTLK: "sow_tlk.tlk",
|
|
},
|
|
}
|
|
}
|
|
|
|
func scanDir(root string, include func(path string) bool) ([]string, []string, error) {
|
|
var files []string
|
|
extSet := map[string]struct{}{}
|
|
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if !include(path) {
|
|
return nil
|
|
}
|
|
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
files = append(files, rel)
|
|
|
|
if strings.HasSuffix(rel, ".json") {
|
|
base := strings.TrimSuffix(filepath.Base(rel), ".json")
|
|
ext := strings.ToLower(filepath.Ext(base))
|
|
if ext != "" {
|
|
extSet[ext] = struct{}{}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
slices.Sort(files)
|
|
|
|
exts := make([]string, 0, len(extSet))
|
|
for ext := range extSet {
|
|
exts = append(exts, ext)
|
|
}
|
|
slices.Sort(exts)
|
|
return files, exts, nil
|
|
}
|
|
|
|
func validateOutputFileName(field, name, expectedExt string) error {
|
|
trimmed := strings.TrimSpace(name)
|
|
if trimmed == "" {
|
|
return fmt.Errorf("%s is required", field)
|
|
}
|
|
if filepath.Base(trimmed) != trimmed {
|
|
return fmt.Errorf("%s must be a file name, not a path: %q", field, name)
|
|
}
|
|
if !strings.EqualFold(filepath.Ext(trimmed), expectedExt) {
|
|
return fmt.Errorf("%s must use %s extension: %q", field, expectedExt, name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sameCleanPath(a, b string) bool {
|
|
return filepath.Clean(a) == filepath.Clean(b)
|
|
}
|