diff --git a/cards_to_excel.bat b/cards_to_excel.bat
new file mode 100644
index 0000000..7e92401
--- /dev/null
+++ b/cards_to_excel.bat
@@ -0,0 +1,7 @@
+@echo off
+setlocal
+chcp 65001 >nul
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\cards\cards_excel.ps1" export
+echo.
+echo Press any key to close this window.
+pause >nul
diff --git a/data/cards.xlsx b/data/cards.xlsx
new file mode 100644
index 0000000..57de5bb
Binary files /dev/null and b/data/cards.xlsx differ
diff --git a/excel_to_cards.bat b/excel_to_cards.bat
new file mode 100644
index 0000000..85fdbe0
--- /dev/null
+++ b/excel_to_cards.bat
@@ -0,0 +1,7 @@
+@echo off
+setlocal
+chcp 65001 >nul
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\cards\cards_excel.ps1" import
+echo.
+echo Press any key to close this window.
+pause >nul
diff --git a/tools/cards/cards_excel.ps1 b/tools/cards/cards_excel.ps1
new file mode 100644
index 0000000..e70d47e
--- /dev/null
+++ b/tools/cards/cards_excel.ps1
@@ -0,0 +1,629 @@
+param(
+ [Parameter(Mandatory = $true, Position = 0)]
+ [ValidateSet('export', 'import')]
+ [string]$Action,
+ [string]$JsonPath,
+ [string]$XlsxPath,
+ [string]$OutJsonPath
+)
+
+$ErrorActionPreference = 'Stop'
+Add-Type -AssemblyName System.IO.Compression.FileSystem
+Add-Type -AssemblyName System.IO.Compression
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
+if ([string]::IsNullOrWhiteSpace($JsonPath)) { $JsonPath = Join-Path $repoRoot 'data\cards.json' }
+if ([string]::IsNullOrWhiteSpace($XlsxPath)) { $XlsxPath = Join-Path $repoRoot 'data\cards.xlsx' }
+if ([string]::IsNullOrWhiteSpace($OutJsonPath)) { $OutJsonPath = $JsonPath }
+
+$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+
+function Escape-Xml([string]$Text) {
+ if ($null -eq $Text) { return '' }
+ return [System.Security.SecurityElement]::Escape($Text)
+}
+
+function Get-ColumnName([int]$Index) {
+ $n = $Index
+ $name = ''
+ while ($n -gt 0) {
+ $n--
+ $name = [char][int](65 + ($n % 26)) + $name
+ $n = [math]::Floor($n / 26)
+ }
+ return $name
+}
+
+function Get-ColumnIndex([string]$Name) {
+ $n = 0
+ foreach ($ch in $Name.ToCharArray()) {
+ if ($ch -match '[A-Z]') {
+ $n = $n * 26 + ([int][char]$ch - 64)
+ }
+ }
+ return $n
+}
+
+function Get-CellRef([int]$Col, [int]$Row) {
+ return (Get-ColumnName $Col) + $Row
+}
+
+function Has-MapKey($Map, $Key) {
+ if ($null -eq $Map) { return $false }
+ if ($null -eq $Key) { return $false }
+ if ($Key -is [string] -and [string]::IsNullOrWhiteSpace($Key)) { return $false }
+ foreach ($existingKey in $Map.Keys) {
+ if ($existingKey -eq $Key) { return $true }
+ }
+ return $false
+}
+
+function Get-ScalarType($Value) {
+ if ($null -eq $Value) { return 'null' }
+ if ($Value -is [bool]) { return 'boolean' }
+ if ($Value -is [byte] -or $Value -is [sbyte] -or
+ $Value -is [int16] -or $Value -is [uint16] -or
+ $Value -is [int32] -or $Value -is [uint32] -or
+ $Value -is [int64] -or $Value -is [uint64] -or
+ $Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) { return 'number' }
+ if ($Value -is [string]) { return 'string' }
+ return 'string'
+}
+
+function Get-CardSchema($Cards) {
+ $schema = [ordered]@{}
+ foreach ($cardEntry in $Cards.PSObject.Properties) {
+ $card = $cardEntry.Value
+ foreach ($prop in $card.PSObject.Properties) {
+ $kind = Get-ScalarType $prop.Value
+ if (-not (Has-MapKey $schema $prop.Name)) {
+ $schema[$prop.Name] = $kind
+ } elseif ($schema[$prop.Name] -ne $kind -and $kind -ne 'null') {
+ $schema[$prop.Name] = 'string'
+ }
+ }
+ }
+ return $schema
+}
+
+function Get-ColumnWidth([string]$Header, [string]$Type) {
+ switch ($Header) {
+ 'id' { return 18 }
+ 'name' { return 24 }
+ 'desc' { return 48 }
+ 'image' { return 36 }
+ 'fx' { return 36 }
+ 'kind' { return 12 }
+ 'class' { return 12 }
+ 'rarity' { return 12 }
+ default {
+ if ($Type -eq 'boolean') { return 10 }
+ if ($Type -eq 'number') { return 12 }
+ return 16
+ }
+ }
+}
+
+function To-InvariantNumber($Value) {
+ return [string]::Format([System.Globalization.CultureInfo]::InvariantCulture, '{0}', $Value)
+}
+
+function New-HeaderCellXml([string]$Ref, [string]$Text) {
+ $escaped = Escape-Xml $Text
+ return "$escaped"
+}
+
+function New-TextCellXml([string]$Ref, [string]$Text) {
+ $escaped = Escape-Xml $Text
+ return "$escaped"
+}
+
+function New-NumberCellXml([string]$Ref, $Value) {
+ if ($null -eq $Value) { return $null }
+ if ($Value -is [string] -and $Value -eq '') { return $null }
+ return "$(To-InvariantNumber $Value)"
+}
+
+function New-BoolCellXml([string]$Ref, $Value) {
+ if ($null -eq $Value) { return $null }
+ if ($Value -is [string] -and $Value -eq '') { return $null }
+ $bool = $false
+ if ($Value -is [bool]) {
+ $bool = $Value
+ } else {
+ $text = [string]$Value
+ if ($text -match '^(?i:true|1|yes|y)$') { $bool = $true }
+ elseif ($text -match '^(?i:false|0|no|n)$') { $bool = $false }
+ else { return $null }
+ }
+ $n = if ($bool) { 1 } else { 0 }
+ return "$n"
+}
+
+function New-CellXml([string]$Ref, $Value, [string]$Type) {
+ switch ($Type) {
+ 'number' { return New-NumberCellXml $Ref $Value }
+ 'boolean' { return New-BoolCellXml $Ref $Value }
+ default {
+ if ($null -eq $Value) {
+ return New-TextCellXml $Ref ''
+ }
+ return New-TextCellXml $Ref ([string]$Value)
+ }
+ }
+}
+
+function Get-WorksheetXml([string]$SheetName, [string[]]$Headers, [object[]]$Rows, [hashtable]$TypeMap) {
+ $maxCol = $Headers.Count
+ $lastCol = Get-ColumnName $maxCol
+ $rowCount = $Rows.Count + 1
+ $colsXml = New-Object System.Collections.Generic.List[string]
+ for ($i = 0; $i -lt $Headers.Count; $i++) {
+ $header = $Headers[$i]
+ $type = if (Has-MapKey $TypeMap $header) { [string]$TypeMap[$header] } else { 'string' }
+ $width = Get-ColumnWidth $header $type
+ $colsXml.Add("
")
+ }
+
+ $rowsXml = New-Object System.Collections.Generic.List[string]
+ $headerCells = New-Object System.Collections.Generic.List[string]
+ for ($i = 0; $i -lt $Headers.Count; $i++) {
+ $headerCells.Add((New-HeaderCellXml (Get-CellRef ($i + 1) 1) $Headers[$i]))
+ }
+ $rowsXml.Add("$($headerCells -join '')
")
+
+ for ($r = 0; $r -lt $Rows.Count; $r++) {
+ $row = $Rows[$r]
+ $cells = New-Object System.Collections.Generic.List[string]
+ for ($c = 0; $c -lt $Headers.Count; $c++) {
+ $header = $Headers[$c]
+ $type = if (Has-MapKey $TypeMap $header) { [string]$TypeMap[$header] } else { 'string' }
+ $value = $null
+ if (Has-MapKey $row $header) { $value = $row[$header] }
+ $cellXml = New-CellXml (Get-CellRef ($c + 1) ($r + 2)) $value $type
+ if ($null -ne $cellXml) { $cells.Add($cellXml) }
+ }
+ $rowsXml.Add("$($cells -join '')
")
+ }
+
+ $sheetView = ''
+ $cols = '' + ($colsXml -join '') + ''
+ $sheetData = '' + ($rowsXml -join '') + ''
+ $autoFilter = ""
+ return @"
+
+
+ $sheetView
+
+ $cols
+ $sheetData
+ $autoFilter
+
+
+"@
+}
+
+function Get-StylesXml {
+ return @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+}
+
+function Get-WorkbookXml([string[]]$SheetNames) {
+ $sheetsXml = New-Object System.Collections.Generic.List[string]
+ for ($i = 0; $i -lt $SheetNames.Count; $i++) {
+ $sheetsXml.Add("")
+ }
+ return @"
+
+
+
+ $($sheetsXml -join '')
+
+
+"@
+}
+
+function Get-WorkbookRelsXml([int]$SheetCount) {
+ $rels = New-Object System.Collections.Generic.List[string]
+ for ($i = 1; $i -le $SheetCount; $i++) {
+ $rels.Add("")
+ }
+ $rels.Add("")
+ return @"
+
+
+ $($rels -join '')
+
+"@
+}
+
+function Get-RootRelsXml {
+ return @"
+
+
+
+
+"@
+}
+
+function Get-ContentTypesXml([int]$SheetCount) {
+ $overrides = New-Object System.Collections.Generic.List[string]
+ for ($i = 1; $i -le $SheetCount; $i++) {
+ $overrides.Add("")
+ }
+ $overrides.Add('')
+ $overrides.Add('')
+ return @"
+
+
+
+
+ $($overrides -join '')
+
+"@
+}
+
+function Write-Xlsx([string]$Path, [hashtable]$Parts) {
+ $dir = Split-Path -Parent $Path
+ if (-not [string]::IsNullOrWhiteSpace($dir) -and -not (Test-Path $dir)) {
+ New-Item -ItemType Directory -Path $dir -Force | Out-Null
+ }
+ if (Test-Path $Path) {
+ Remove-Item -LiteralPath $Path -Force
+ }
+ $file = [System.IO.File]::Open($Path, [System.IO.FileMode]::Create, [System.IO.FileAccess]::ReadWrite)
+ try {
+ $zip = New-Object System.IO.Compression.ZipArchive($file, [System.IO.Compression.ZipArchiveMode]::Create, $false)
+ try {
+ foreach ($entryName in $Parts.Keys) {
+ $entry = $zip.CreateEntry($entryName)
+ $stream = $entry.Open()
+ $writer = New-Object System.IO.StreamWriter($stream, $utf8NoBom)
+ try {
+ $writer.Write([string]$Parts[$entryName])
+ } finally {
+ $writer.Dispose()
+ $stream.Dispose()
+ }
+ }
+ } finally {
+ $zip.Dispose()
+ }
+ } finally {
+ $file.Dispose()
+ }
+}
+
+function Read-XlsxXml([string]$Path, [string]$EntryName) {
+ $zip = [System.IO.Compression.ZipFile]::OpenRead($Path)
+ try {
+ $entry = $zip.GetEntry($EntryName)
+ if ($null -eq $entry) { throw "Missing XLSX entry: $EntryName" }
+ $stream = $entry.Open()
+ try {
+ $reader = New-Object System.IO.StreamReader($stream, $utf8NoBom)
+ try { return $reader.ReadToEnd() } finally { $reader.Dispose() }
+ } finally {
+ $stream.Dispose()
+ }
+ } finally {
+ $zip.Dispose()
+ }
+}
+
+function Read-SharedStrings([string]$Path) {
+ try {
+ $xmlText = Read-XlsxXml $Path 'xl/sharedStrings.xml'
+ } catch {
+ return @()
+ }
+ [xml]$xml = $xmlText
+ $ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable)
+ $ns.AddNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main')
+ $items = $xml.SelectNodes('/x:sst/x:si', $ns)
+ $values = New-Object System.Collections.Generic.List[string]
+ foreach ($item in $items) {
+ $values.Add([string]$item.InnerText)
+ }
+ return $values.ToArray()
+}
+
+function Read-WorksheetRows([string]$XmlText, [string[]]$SharedStrings) {
+ [xml]$xml = $XmlText
+ $ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable)
+ $ns.AddNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main')
+ $rows = $xml.SelectNodes('/x:worksheet/x:sheetData/x:row', $ns)
+ $parsed = @()
+ foreach ($row in $rows) {
+ $cells = @{}
+ foreach ($cell in @($row.ChildNodes)) {
+ if ($cell.Name -ne 'c') { continue }
+ $ref = [string]$cell.Attributes['r'].Value
+ $col = Get-ColumnIndex (($ref -replace '\d+$', ''))
+ $type = [string]$cell.Attributes['t'].Value
+ $text = [string]$cell.InnerText
+ if ($type -eq 's' -and $text -match '^\d+$') {
+ $index = [int]$text
+ if ($index -ge 0 -and $index -lt $SharedStrings.Count) {
+ $text = [string]$SharedStrings[$index]
+ }
+ }
+ $cells[$col] = $text
+ }
+ $parsed += ,$cells
+ }
+ return $parsed
+}
+
+function Convert-CellValue([string]$Text, [string]$Type) {
+ if ($null -eq $Text -or $Text -eq '') { return $null }
+ switch ($Type) {
+ 'number' {
+ $num = 0
+ if ([double]::TryParse($Text, [System.Globalization.NumberStyles]::Any, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$num)) {
+ if ([math]::Abs($num - [math]::Round($num)) -lt 0.0000001) { return [int64][math]::Round($num) }
+ return $num
+ }
+ return $null
+ }
+ 'boolean' {
+ if ($Text -match '^(?i:true|1|yes|y)$') { return $true }
+ if ($Text -match '^(?i:false|0|no|n)$') { return $false }
+ return $null
+ }
+ default {
+ return $Text
+ }
+ }
+}
+
+function Export-Cards {
+ $source = Get-Content -LiteralPath $JsonPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $schema = Get-CardSchema $source.cards
+ $cardCore = @('id', 'name', 'cost', 'kind', 'rarity', 'class', 'desc', 'image', 'fx')
+ $cardExtras = @($schema.Keys | Where-Object { $_ -notin $cardCore } | Sort-Object)
+ $cardHeaders = @($cardCore + $cardExtras)
+
+ $maxDeckSize = 0
+ foreach ($deckEntry in $source.starterDecks.PSObject.Properties) {
+ $deckSize = @($deckEntry.Value).Count
+ if ($deckSize -gt $maxDeckSize) {
+ $maxDeckSize = $deckSize
+ }
+ }
+ if ($maxDeckSize -lt 1) { $maxDeckSize = 1 }
+
+ $starterDeckHeaders = New-Object System.Collections.Generic.List[string]
+ $starterDeckHeaders.Add('class')
+ for ($i = 1; $i -le $maxDeckSize; $i++) {
+ $starterDeckHeaders.Add("slot$i")
+ }
+
+ $cardRows = New-Object System.Collections.Generic.List[object]
+ foreach ($cardEntry in $source.cards.PSObject.Properties) {
+ $cardId = $cardEntry.Name
+ $card = $cardEntry.Value
+ $row = [ordered]@{ id = $cardId }
+ foreach ($header in $cardHeaders) {
+ if ($header -eq 'id') { continue }
+ if ($card.PSObject.Properties.Name -contains $header) {
+ $row[$header] = $card.$header
+ } else {
+ $row[$header] = $null
+ }
+ }
+ $cardRows.Add($row)
+ }
+
+ $deckRows = New-Object System.Collections.Generic.List[object]
+ foreach ($deckEntry in $source.starterDecks.PSObject.Properties) {
+ $cls = $deckEntry.Name
+ $deck = @($deckEntry.Value)
+ $row = [ordered]@{ class = $cls }
+ for ($i = 1; $i -le $maxDeckSize; $i++) {
+ $key = "slot$i"
+ $row[$key] = if ($i -le $deck.Count) { $deck[$i - 1] } else { $null }
+ }
+ $deckRows.Add($row)
+ }
+
+ $cardSheet = Get-WorksheetXml 'Cards' $cardHeaders $cardRows $schema
+ $deckTypeMap = [ordered]@{ class = 'string' }
+ for ($i = 1; $i -le $maxDeckSize; $i++) { $deckTypeMap["slot$i"] = 'string' }
+ $deckSheet = Get-WorksheetXml 'StarterDecks' $starterDeckHeaders $deckRows $deckTypeMap
+
+ $parts = [ordered]@{
+ '[Content_Types].xml' = (Get-ContentTypesXml 2)
+ '_rels/.rels' = (Get-RootRelsXml)
+ 'xl/workbook.xml' = (Get-WorkbookXml @('Cards', 'StarterDecks'))
+ 'xl/_rels/workbook.xml.rels' = (Get-WorkbookRelsXml 2)
+ 'xl/styles.xml' = (Get-StylesXml)
+ 'xl/worksheets/sheet1.xml' = $cardSheet
+ 'xl/worksheets/sheet2.xml' = $deckSheet
+ }
+
+ Write-Host "Source JSON: $JsonPath"
+ Write-Host "Target XLSX: $XlsxPath"
+ Write-Xlsx $XlsxPath $parts
+ Write-Host "Excel export complete: $XlsxPath"
+}
+
+function Import-Cards {
+ $source = Get-Content -LiteralPath $JsonPath -Raw -Encoding utf8 | ConvertFrom-Json
+ $schema = Get-CardSchema $source.cards
+ $origCardOrders = @{}
+ foreach ($cardEntry in $source.cards.PSObject.Properties) {
+ $origCardOrders[$cardEntry.Name] = @($cardEntry.Value.PSObject.Properties.Name)
+ }
+ $origDeckOrder = @($source.starterDecks.PSObject.Properties.Name)
+
+ $sharedStrings = Read-SharedStrings $XlsxPath
+ $cardsXml = Read-XlsxXml $XlsxPath 'xl/worksheets/sheet1.xml'
+ $deckXml = Read-XlsxXml $XlsxPath 'xl/worksheets/sheet2.xml'
+ $cardRowsRaw = Read-WorksheetRows $cardsXml $sharedStrings
+ $deckRowsRaw = Read-WorksheetRows $deckXml $sharedStrings
+
+ if ($cardRowsRaw.Count -lt 2) { throw 'Cards sheet has no data rows.' }
+ if ($deckRowsRaw.Count -lt 2) { throw 'StarterDecks sheet has no data rows.' }
+
+ $cardHeaderMap = $cardRowsRaw[0]
+ $cardHeaders = @($cardHeaderMap.Keys | Sort-Object)
+ $orderedCardHeaders = New-Object System.Collections.Generic.List[string]
+ foreach ($col in $cardHeaders) {
+ $header = $cardHeaderMap[$col]
+ if ([string]::IsNullOrWhiteSpace($header)) { continue }
+ $orderedCardHeaders.Add($header)
+ }
+
+ $newCards = [ordered]@{}
+ for ($r = 1; $r -lt $cardRowsRaw.Count; $r++) {
+ $row = $cardRowsRaw[$r]
+ $cardId = $null
+ $rowValues = @{}
+ for ($c = 0; $c -lt $orderedCardHeaders.Count; $c++) {
+ $header = $orderedCardHeaders[$c]
+ $text = $null
+ if (Has-MapKey $row ($c + 1)) { $text = $row[$c + 1] }
+ if ($header -eq 'id') {
+ $cardId = [string]$text
+ continue
+ }
+ $rowValues[$header] = $text
+ }
+ if (-not [string]::IsNullOrWhiteSpace($cardId)) {
+ $cardObj = [ordered]@{}
+ $fieldOrder = New-Object System.Collections.Generic.List[string]
+ if ($origCardOrders.ContainsKey($cardId)) {
+ foreach ($name in @($origCardOrders[$cardId])) {
+ if ($name -ne 'id' -and -not $fieldOrder.Contains($name)) {
+ $fieldOrder.Add($name)
+ }
+ }
+ }
+ foreach ($name in $orderedCardHeaders) {
+ if ($name -ne 'id' -and -not $fieldOrder.Contains($name)) {
+ $fieldOrder.Add($name)
+ }
+ }
+ foreach ($header in $fieldOrder) {
+ $text = $null
+ if (Has-MapKey $rowValues $header) { $text = $rowValues[$header] }
+ $type = if (Has-MapKey $schema $header) { [string]$schema[$header] } else { 'string' }
+ $value = Convert-CellValue $text $type
+ if ($null -eq $value) { continue }
+ $cardObj[$header] = $value
+ }
+ $newCards[$cardId] = $cardObj
+ }
+ }
+
+ $deckHeaderMap = $deckRowsRaw[0]
+ $deckHeaderCols = @($deckHeaderMap.Keys | Sort-Object)
+ $orderedDeckHeaders = New-Object System.Collections.Generic.List[string]
+ foreach ($col in $deckHeaderCols) {
+ $header = $deckHeaderMap[$col]
+ if ([string]::IsNullOrWhiteSpace($header)) { continue }
+ $orderedDeckHeaders.Add($header)
+ }
+
+ $newDecks = [ordered]@{}
+ for ($r = 1; $r -lt $deckRowsRaw.Count; $r++) {
+ $row = $deckRowsRaw[$r]
+ $cls = $null
+ $deckValues = @{}
+ for ($c = 0; $c -lt $orderedDeckHeaders.Count; $c++) {
+ $header = $orderedDeckHeaders[$c]
+ $text = $null
+ if (Has-MapKey $row ($c + 1)) { $text = $row[$c + 1] }
+ if ($header -eq 'class') {
+ $cls = [string]$text
+ continue
+ }
+ $deckValues[$header] = $text
+ }
+ if (-not [string]::IsNullOrWhiteSpace($cls)) {
+ $deck = New-Object System.Collections.Generic.List[string]
+ foreach ($header in $orderedDeckHeaders) {
+ if ($header -eq 'class') { continue }
+ $text = $null
+ if (Has-MapKey $deckValues $header) { $text = $deckValues[$header] }
+ if (-not [string]::IsNullOrWhiteSpace([string]$text)) {
+ $deck.Add([string]$text)
+ }
+ }
+ $newDecks[$cls] = $deck.ToArray()
+ }
+ }
+
+ if ($origDeckOrder.Count -gt 0) {
+ $orderedDecks = [ordered]@{}
+ foreach ($cls in $origDeckOrder) {
+ if (Has-MapKey $newDecks $cls) {
+ $orderedDecks[$cls] = $newDecks[$cls]
+ }
+ }
+ foreach ($entry in $newDecks.GetEnumerator()) {
+ if (-not (Has-MapKey $orderedDecks $entry.Key)) {
+ $orderedDecks[$entry.Key] = $entry.Value
+ }
+ }
+ $newDecks = $orderedDecks
+ }
+
+ $out = [ordered]@{
+ cards = $newCards
+ starterDecks = $newDecks
+ }
+
+ $json = $out | ConvertTo-Json -Depth 64
+ Write-Host "Source XLSX: $XlsxPath"
+ Write-Host "Target JSON: $OutJsonPath"
+ [System.IO.File]::WriteAllText($OutJsonPath, $json, $utf8NoBom)
+ Write-Host "JSON import complete: $OutJsonPath"
+}
+
+switch ($Action) {
+ 'export' { Export-Cards }
+ 'import' { Import-Cards }
+}