Add wesnoth-map-diff
This commit is contained in:
parent
a4955a39fa
commit
bc387de7e9
23 changed files with 13658 additions and 0 deletions
82
.github/workflows/map-diff.yml
vendored
Normal file
82
.github/workflows/map-diff.yml
vendored
Normal file
|
@ -0,0 +1,82 @@
|
|||
name: Map Diff
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.map'
|
||||
|
||||
jobs:
|
||||
comment-map-diff:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3.0.0
|
||||
- uses: actions/setup-node@v3.0.0
|
||||
with:
|
||||
node-version: '16'
|
||||
- name: Package install
|
||||
run: |
|
||||
cd ./utils/wesnoth-map-diff
|
||||
npm install
|
||||
- name: Package build
|
||||
run: |
|
||||
cd ./utils/wesnoth-map-diff
|
||||
npm run build:prod
|
||||
- name: Get maps diff
|
||||
id: get-maps-diff
|
||||
run: |
|
||||
cd ./utils/wesnoth-map-diff
|
||||
|
||||
## Get Maps
|
||||
git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
map_paths=($(git diff --name-only HEAD ${{ github.event.pull_request.base.sha }} | grep ".map$"))
|
||||
|
||||
for map_path in "${map_paths[@]}"
|
||||
do
|
||||
map_filename=$(basename "$map_path")
|
||||
git show ${{ github.event.pull_request.base.sha }}:$map_path > $map_filename
|
||||
done
|
||||
|
||||
## Run map diff
|
||||
diff_images=()
|
||||
|
||||
for map_path in "${map_paths[@]}"
|
||||
do
|
||||
map_filename=$(basename "$map_path")
|
||||
output_filename=$(echo "$map_filename" | perl -pe 's/map$/png/')
|
||||
|
||||
node ./build/index.js "./$map_filename" "../../$map_path" "./$output_filename"
|
||||
|
||||
diff_images+=("$output_filename")
|
||||
done
|
||||
|
||||
## Compress images
|
||||
sudo apt-get install -y pngquant
|
||||
|
||||
for diff_image in "${diff_images[@]}"
|
||||
do
|
||||
pngquant "$diff_image" -o "$diff_image" --force
|
||||
done
|
||||
|
||||
## Write comment body
|
||||
comment_body=""
|
||||
|
||||
for i in "${!diff_images[@]}"
|
||||
do
|
||||
map_path=${map_paths[$i]}
|
||||
diff_image=${diff_images[$i]}
|
||||
|
||||
image_link=$(curl -X POST "https://api.imgur.com/3/upload" \
|
||||
-F "image=@\"$diff_image\"" | jq ".data.link" -r)
|
||||
|
||||
comment_body="$comment_body<h3>$map_path</h3><img src=\"$image_link\" /><br />"
|
||||
done
|
||||
|
||||
echo "::set-output name=COMMENT_BODY::$comment_body"
|
||||
- name: Add comment
|
||||
uses: peter-evans/create-or-update-comment@v1.4.5
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
edit-mode: replace
|
||||
body: |
|
||||
${{ steps.get-maps-diff.outputs.COMMENT_BODY }}
|
1
utils/wesnoth-map-diff/.eslintignore
Normal file
1
utils/wesnoth-map-diff/.eslintignore
Normal file
|
@ -0,0 +1 @@
|
|||
build
|
81
utils/wesnoth-map-diff/.eslintrc.js
Normal file
81
utils/wesnoth-map-diff/.eslintrc.js
Normal file
|
@ -0,0 +1,81 @@
|
|||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: [
|
||||
'@typescript-eslint',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
},
|
||||
],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/member-delimiter-style': ['error', {
|
||||
multiline: {
|
||||
delimiter: 'comma',
|
||||
requireLast: true,
|
||||
},
|
||||
singleline: {
|
||||
delimiter: 'comma',
|
||||
requireLast: false,
|
||||
},
|
||||
}],
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-empty-interface': [
|
||||
'error',
|
||||
{
|
||||
allowSingleExtends: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'arrow-parens': 'off',
|
||||
'comma-dangle': [
|
||||
'error',
|
||||
{
|
||||
arrays: 'always-multiline',
|
||||
exports: 'always-multiline',
|
||||
functions: 'never',
|
||||
imports: 'always-multiline',
|
||||
objects: 'always-multiline',
|
||||
},
|
||||
],
|
||||
'function-paren-newline': ['error', 'consistent'],
|
||||
'quote-props': ['error', 'as-needed'],
|
||||
'max-len': [
|
||||
'error',
|
||||
{
|
||||
code: 120,
|
||||
ignoreComments: true,
|
||||
ignoreRegExpLiterals: true,
|
||||
ignoreStrings: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
ignoreTrailingComments: true,
|
||||
ignoreUrls: true,
|
||||
tabWidth: 2,
|
||||
},
|
||||
],
|
||||
'multiline-ternary': ['error', 'always-multiline'],
|
||||
'no-unused-expressions': ['error', { allowTernary: true }],
|
||||
'no-multiple-empty-lines': [
|
||||
'error',
|
||||
{
|
||||
max: 1,
|
||||
maxEOF: 1,
|
||||
},
|
||||
],
|
||||
'implicit-arrow-linebreak': 'off',
|
||||
'import/extensions': 'off',
|
||||
'import/no-unresolved': 'off',
|
||||
semi: ['error', 'never'],
|
||||
'space-before-function-paren': ['error', 'always'],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'operator-linebreak': [
|
||||
'error',
|
||||
'before',
|
||||
{ overrides: { '=': 'after', ':': 'before', '?': 'before' } },
|
||||
],
|
||||
},
|
||||
}
|
4
utils/wesnoth-map-diff/.gitignore
vendored
Normal file
4
utils/wesnoth-map-diff/.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
.eslintcache
|
||||
|
||||
node_modules/
|
||||
build/
|
39
utils/wesnoth-map-diff/README.md
Normal file
39
utils/wesnoth-map-diff/README.md
Normal file
|
@ -0,0 +1,39 @@
|
|||
# wesnoth-map-diff
|
||||
|
||||
> 🗺 Print the diff between two maps
|
||||
|
||||
## Setup
|
||||
|
||||
1 - Make sure you have Node on your system. If you don't have, install it. You can use [nvm](https://github.com/nvm-sh/nvm).
|
||||
|
||||
2 - Install the project dependencies:
|
||||
|
||||
```
|
||||
npm i
|
||||
```
|
||||
|
||||
3 - Build it:
|
||||
|
||||
```
|
||||
npm run build:dev
|
||||
```
|
||||
|
||||
4 - Run it:
|
||||
|
||||
```
|
||||
node ./build/index.js old.map new.map output.png
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Run tests:
|
||||
|
||||
```
|
||||
npm run test
|
||||
```
|
||||
|
||||
Run lint:
|
||||
|
||||
```
|
||||
npm run lint
|
||||
```
|
6
utils/wesnoth-map-diff/babel.config.js
Normal file
6
utils/wesnoth-map-diff/babel.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', { targets: { node: 'current' } }],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
}
|
3
utils/wesnoth-map-diff/jest.config.js
Normal file
3
utils/wesnoth-map-diff/jest.config.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
setupFilesAfterEnv: ['./setup-jest.js'],
|
||||
}
|
12550
utils/wesnoth-map-diff/package-lock.json
generated
Normal file
12550
utils/wesnoth-map-diff/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
utils/wesnoth-map-diff/package.json
Normal file
30
utils/wesnoth-map-diff/package.json
Normal file
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "wesnoth-map-diff",
|
||||
"version": "0.0.1",
|
||||
"description": "Visual map differ for Battle for Wesnoth",
|
||||
"scripts": {
|
||||
"build:dev": "tsc -p tsconfig-dev.json",
|
||||
"build:prod": "tsc -p tsconfig-prod.json",
|
||||
"test": "jest",
|
||||
"lint": "eslint . --cache"
|
||||
},
|
||||
"author": "macabeus",
|
||||
"license": "GNU GPL v2+",
|
||||
"dependencies": {
|
||||
"jimp": "0.16.1",
|
||||
"parsimmon": "1.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "7.16.11",
|
||||
"@babel/preset-typescript": "7.16.7",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/parsimmon": "1.10.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.12.1",
|
||||
"@typescript-eslint/parser": "5.12.1",
|
||||
"eslint": "8.10.0",
|
||||
"fast-check": "2.21.0",
|
||||
"jest": "27.5.0",
|
||||
"jest-fast-check": "1.0.2",
|
||||
"typescript": "4.5.5"
|
||||
}
|
||||
}
|
14
utils/wesnoth-map-diff/setup-jest.d.ts
vendored
Normal file
14
utils/wesnoth-map-diff/setup-jest.d.ts
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
interface CustomMatchers<R = unknown> {
|
||||
parserGot(value: unknown): R,
|
||||
parserFailedWith(expected: string[]): R,
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace jest {
|
||||
interface Expect extends CustomMatchers {}
|
||||
interface Matchers<R> extends CustomMatchers<R> {}
|
||||
interface InverseAsymmetricMatchers extends CustomMatchers {}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
39
utils/wesnoth-map-diff/setup-jest.js
Normal file
39
utils/wesnoth-map-diff/setup-jest.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
expect.extend({
|
||||
parserGot (received, value) {
|
||||
try {
|
||||
expect(received).toMatchObject({
|
||||
status: true,
|
||||
value,
|
||||
})
|
||||
|
||||
return {
|
||||
pass: true,
|
||||
message: () => `Expects for ${JSON.stringify(value)}`,
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
pass: false,
|
||||
message: () => e.matcherResult.message,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
parserFailedWith (received, expected) {
|
||||
try {
|
||||
expect(received).toMatchObject({
|
||||
status: false,
|
||||
expected,
|
||||
})
|
||||
|
||||
return {
|
||||
pass: true,
|
||||
message: () => `Expects for ${JSON.stringify(expected)}`,
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
pass: false,
|
||||
message: () => e.matcherResult.message,
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
60
utils/wesnoth-map-diff/src/images.ts
Normal file
60
utils/wesnoth-map-diff/src/images.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import path from 'path'
|
||||
import Jimp from 'jimp'
|
||||
import { parseWmlFile } from './wml/parser'
|
||||
import rootByTagName from './wml/rootByTagName'
|
||||
|
||||
type ImagesDict = { tile: { [baseCode: string]: Jimp }, focus: Jimp, flag: Jimp }
|
||||
|
||||
const getDictTerrainType2ImagesPath = async () => {
|
||||
const terratinCfgPath = path.resolve(__dirname, '../../../data/core/terrain.cfg')
|
||||
|
||||
const terrain = await parseWmlFile(terratinCfgPath)
|
||||
|
||||
const dictTerrainType2ImagesPath = rootByTagName(terrain)
|
||||
.terrain_type
|
||||
.reduce((acc, terrainType) => {
|
||||
if (terrainType.symbol_image === undefined) {
|
||||
return acc
|
||||
}
|
||||
|
||||
acc[terrainType.string.value] = terrainType.symbol_image.value
|
||||
return acc
|
||||
}, {} as { [terrainType: string]: string })
|
||||
|
||||
return dictTerrainType2ImagesPath
|
||||
}
|
||||
|
||||
const readTerrainImages = async () => {
|
||||
const imageBasepath = path.resolve(__dirname, '../../../data/core/images/terrain')
|
||||
|
||||
const dictTerrainType2ImagesPath = await getDictTerrainType2ImagesPath()
|
||||
|
||||
const promises = Object
|
||||
.entries(dictTerrainType2ImagesPath)
|
||||
.map(async ([terrainType, imageName]) => {
|
||||
const image = await Jimp.read(`${imageBasepath}/${imageName}.png`)
|
||||
return [terrainType, image]
|
||||
})
|
||||
|
||||
const result = Object.fromEntries(await Promise.all(promises))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const getImages = async (): Promise<ImagesDict> => {
|
||||
const focusPath = path.resolve(__dirname, '../../../images/editor/brush.png')
|
||||
const flagPath = path.resolve(__dirname, '../../../data/core/images/flags/flag-1.png')
|
||||
|
||||
const mapTileCodeToImage = readTerrainImages()
|
||||
const focus = Jimp.read(focusPath)
|
||||
const flag = Jimp.read(flagPath)
|
||||
|
||||
return {
|
||||
tile: await mapTileCodeToImage,
|
||||
focus: await focus,
|
||||
flag: await flag,
|
||||
}
|
||||
}
|
||||
|
||||
export { ImagesDict }
|
||||
export default getImages
|
17
utils/wesnoth-map-diff/src/index.ts
Normal file
17
utils/wesnoth-map-diff/src/index.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import getImages from './images'
|
||||
import * as tilemap from './tilemap'
|
||||
import paint from './paint'
|
||||
|
||||
const main = async () => {
|
||||
const [oldMapPath, newMapPath, outputFilename] = [process.argv[2], process.argv[3], process.argv[4]]
|
||||
|
||||
const [oldTilemap, newTilemap, images] = await Promise.all([
|
||||
tilemap.parseFile(oldMapPath),
|
||||
tilemap.parseFile(newMapPath),
|
||||
getImages(),
|
||||
])
|
||||
|
||||
await paint(oldTilemap, newTilemap, outputFilename, images)
|
||||
}
|
||||
|
||||
main()
|
97
utils/wesnoth-map-diff/src/paint.ts
Normal file
97
utils/wesnoth-map-diff/src/paint.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
import Jimp from 'jimp'
|
||||
import * as tilemap from './tilemap'
|
||||
import type { Tilemap } from './tilemap'
|
||||
import type { ImagesDict } from './images'
|
||||
|
||||
type Side = 'left' | 'right'
|
||||
|
||||
const tileImageSize = 72
|
||||
|
||||
const imageSize = (map: Tilemap) => {
|
||||
const { tilemapWidth, tilemapHeight } = tilemap.size(map)
|
||||
|
||||
const imageHeight = tilemapHeight * tileImageSize
|
||||
const imageWidth = tileImageSize * tilemapWidth - (tilemapWidth - 1) * 18
|
||||
|
||||
return { width: imageWidth, height: imageHeight }
|
||||
}
|
||||
|
||||
const getTileImageCoordenates = (tileX: number, tileY: number) => {
|
||||
const imageX = tileX * tileImageSize - 18 * tileX
|
||||
const imageY = tileX % 2 === 0
|
||||
? tileY * tileImageSize
|
||||
: tileY * tileImageSize - tileImageSize / 2
|
||||
|
||||
return [imageX, imageY]
|
||||
}
|
||||
|
||||
const producePainters = (output: Jimp, images: ImagesDict, leftPadding: number) => {
|
||||
const paintTile = (x: number, y: number, baseCode: string) => {
|
||||
const baseImage = images.tile[baseCode]
|
||||
output.composite(baseImage, x + leftPadding, y)
|
||||
}
|
||||
|
||||
// todo: we should use the defaultBase correctly
|
||||
const paintMisc = (x: number, y: number, baseCode: string, miscCode: string) => {
|
||||
if (!miscCode) {
|
||||
return
|
||||
}
|
||||
|
||||
const misctileImage = images.tile[`${baseCode}^${miscCode}`] || images.tile[`^${miscCode}`]
|
||||
output.composite(misctileImage, x + leftPadding, y)
|
||||
}
|
||||
|
||||
// todo: the flag color should follow the player number
|
||||
const paintPlayer = (x: number, y: number, player: string) => {
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
output.composite(images.flag, x + leftPadding, y)
|
||||
}
|
||||
|
||||
return { paintTile, paintMisc, paintPlayer }
|
||||
}
|
||||
|
||||
const paint = async (
|
||||
oldTilemap: Tilemap,
|
||||
newTilemap: Tilemap,
|
||||
outputFilename: string,
|
||||
images: ImagesDict
|
||||
) => {
|
||||
const { height, width } = imageSize(oldTilemap)
|
||||
|
||||
const diffImageWidth = width * 2 + tileImageSize
|
||||
const diffImageHeight = height
|
||||
|
||||
new Jimp(diffImageWidth, diffImageHeight, (_err, output) => {
|
||||
const paintTilemap = (map: Tilemap, side: Side) => {
|
||||
const leftPadding = side === 'left'
|
||||
? 0
|
||||
: width + tileImageSize
|
||||
|
||||
const { paintTile, paintMisc, paintPlayer } = producePainters(output, images, leftPadding)
|
||||
|
||||
tilemap.walkthrough(map, ({ x, y, baseCode, miscCode, player }) => {
|
||||
const [imageX, imageY] = getTileImageCoordenates(x, y)
|
||||
|
||||
paintTile(imageX, imageY, baseCode)
|
||||
paintMisc(imageX, imageY, baseCode, miscCode)
|
||||
paintPlayer(imageX, imageY, player)
|
||||
})
|
||||
|
||||
tilemap.diff(oldTilemap, newTilemap).forEach(([x, y]) => {
|
||||
const [imageX, imageY] = getTileImageCoordenates(x, y)
|
||||
|
||||
output.composite(images.focus, imageX + leftPadding, imageY)
|
||||
})
|
||||
}
|
||||
|
||||
paintTilemap(oldTilemap, 'left')
|
||||
paintTilemap(newTilemap, 'right')
|
||||
|
||||
output.write(outputFilename)
|
||||
})
|
||||
}
|
||||
|
||||
export default paint
|
98
utils/wesnoth-map-diff/src/tilemap.ts
Normal file
98
utils/wesnoth-map-diff/src/tilemap.ts
Normal file
|
@ -0,0 +1,98 @@
|
|||
import fs from 'fs'
|
||||
|
||||
type Tilemap = {
|
||||
baseCode: string,
|
||||
miscCode: string,
|
||||
player: string,
|
||||
}[][]
|
||||
|
||||
const parseRawMap = (rawMap: string) => {
|
||||
return rawMap
|
||||
.slice(0, -1)
|
||||
.split('\n')
|
||||
.map(
|
||||
row =>
|
||||
row
|
||||
.split(', ')
|
||||
.map((rawTile) => {
|
||||
const [baseCode, miscCode] = rawTile.replace(/\d\s/, '').split('^')
|
||||
const [player] = /\d/.test(rawTile) ? rawTile.match(/\d/)! : []
|
||||
|
||||
return { baseCode, miscCode, player }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const parseFile = (path: string) => {
|
||||
return new Promise<Tilemap>((resolve, reject) => {
|
||||
fs.readFile(path, (err, data) => {
|
||||
if (err !== null) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
|
||||
const raw = data.toString()
|
||||
const parsed = parseRawMap(raw)
|
||||
resolve(parsed)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const size = (map: Tilemap) => ({ tilemapWidth: map[0].length, tilemapHeight: map.length })
|
||||
|
||||
type WalkthroughCallback =
|
||||
(
|
||||
{
|
||||
x,
|
||||
y,
|
||||
baseCode,
|
||||
miscCode,
|
||||
player,
|
||||
}: {
|
||||
x: number,
|
||||
y: number,
|
||||
baseCode: string,
|
||||
miscCode: string,
|
||||
player: string,
|
||||
}
|
||||
) => void
|
||||
const walkthrough = (tilemap: Tilemap, callback: WalkthroughCallback) => {
|
||||
let x = 0
|
||||
let y = 0
|
||||
|
||||
for (let i = 0; i < tilemap.length * tilemap[0].length; i += 1) {
|
||||
const tile = tilemap[y][x]
|
||||
|
||||
callback({
|
||||
x,
|
||||
y,
|
||||
baseCode: tile.baseCode,
|
||||
miscCode: tile.miscCode,
|
||||
player: tile.player,
|
||||
})
|
||||
|
||||
x += 1
|
||||
if (x === tilemap[0].length) {
|
||||
y += 1
|
||||
x = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const diff = (left: Tilemap, right: Tilemap) => {
|
||||
const diffTiles: Array<[number, number]> = []
|
||||
|
||||
walkthrough(left, ({ x, y }) => {
|
||||
if (
|
||||
left[y][x].baseCode !== right[y][x].baseCode
|
||||
|| left[y][x].miscCode !== right[y][x].miscCode
|
||||
|| left[y][x].player !== right[y][x].player
|
||||
) {
|
||||
diffTiles.push([x, y])
|
||||
}
|
||||
})
|
||||
|
||||
return diffTiles
|
||||
}
|
||||
|
||||
export { Tilemap, parseFile, size, walkthrough, diff }
|
140
utils/wesnoth-map-diff/src/wml/parser.ts
Normal file
140
utils/wesnoth-map-diff/src/wml/parser.ts
Normal file
|
@ -0,0 +1,140 @@
|
|||
import { createLanguage, seqMap, alt, regex, string, whitespace, Result } from 'parsimmon'
|
||||
import fs from 'fs'
|
||||
|
||||
const WmlLang = createLanguage({
|
||||
File: (r) => {
|
||||
return r
|
||||
.TopLevel
|
||||
.many()
|
||||
.map((nodes) => {
|
||||
const tagsList = nodes.filter(node => typeof node !== 'string')
|
||||
return tagsList
|
||||
})
|
||||
},
|
||||
|
||||
TopLevel: (r) => {
|
||||
return alt(whitespace, r.Comment, r.FullTag)
|
||||
},
|
||||
|
||||
Comment: () => {
|
||||
return regex(/\s*#.*\n/)
|
||||
},
|
||||
|
||||
FullTag: (r) => {
|
||||
return r
|
||||
.OpenTag
|
||||
.chain((tagName) => {
|
||||
return alt(
|
||||
whitespace,
|
||||
r.FullAttribute,
|
||||
r.Comment
|
||||
)
|
||||
.desc('Tag body')
|
||||
.many()
|
||||
.map((lines) => {
|
||||
const attrsArray = lines.filter(line => typeof line !== 'string')
|
||||
const attrsObj = attrsArray.reduce((acc, attr) => {
|
||||
acc[attr.key] = attr.value
|
||||
return acc
|
||||
}, {})
|
||||
return [tagName, attrsObj]
|
||||
})
|
||||
.skip(
|
||||
string(`[/${tagName}]`)
|
||||
.desc(`Close tag for [${tagName}]`)
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
OpenTag: () => {
|
||||
return seqMap(
|
||||
string('['),
|
||||
regex(/[a-zA-Z0-9_]+/),
|
||||
string(']'),
|
||||
(_, tagName) => tagName
|
||||
)
|
||||
},
|
||||
|
||||
FullAttribute: (r) => {
|
||||
return seqMap(
|
||||
r.AttributeKey,
|
||||
r.AttributeValue,
|
||||
(key, value) => (
|
||||
{ key, value }
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
AttributeKey: () => {
|
||||
return seqMap(
|
||||
regex(/[a-zA-Z0-9_]+/),
|
||||
regex(/\s*=\s*/),
|
||||
(key) => key
|
||||
)
|
||||
},
|
||||
|
||||
AttributeValue: (r) => {
|
||||
return alt(
|
||||
r.AttributeValueString,
|
||||
r.AttributeValueText
|
||||
)
|
||||
},
|
||||
|
||||
AttributeValueText: () => {
|
||||
return seqMap(
|
||||
regex(/(_ )?/),
|
||||
regex(/[^+\n]*/),
|
||||
(translatable, value) => (
|
||||
{
|
||||
value: value.trim(),
|
||||
translatable: Boolean(translatable),
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
AttributeValueString: () => {
|
||||
return seqMap(
|
||||
regex(/(_ )?/),
|
||||
string('"'),
|
||||
regex(/([^"]|"")*/),
|
||||
string('"'),
|
||||
(translatable, _, value) => (
|
||||
{
|
||||
value,
|
||||
translatable: Boolean(translatable),
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
type WmlParsed = Array<
|
||||
[
|
||||
string,
|
||||
{ [attributeName: string]: { translatable: boolean, value: string } }
|
||||
]
|
||||
>
|
||||
|
||||
const parseWml = (source: string) =>
|
||||
WmlLang.File.parse(source) as Result<WmlParsed>
|
||||
|
||||
const tryParseWml = (source: string) =>
|
||||
WmlLang.File.tryParse(source) as WmlParsed
|
||||
|
||||
const parseWmlFile = (path: string) => {
|
||||
return new Promise<WmlParsed>((resolve, reject) => {
|
||||
fs.readFile(path, (err, data) => {
|
||||
if (err !== null) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
|
||||
const rawTerrainCfg = data.toString()
|
||||
const parsed = tryParseWml(rawTerrainCfg)
|
||||
resolve(parsed)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export { WmlParsed, parseWml, tryParseWml, parseWmlFile }
|
14
utils/wesnoth-map-diff/src/wml/rootByTagName.ts
Normal file
14
utils/wesnoth-map-diff/src/wml/rootByTagName.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import type { WmlParsed } from './parser'
|
||||
|
||||
const rootByTagName = (parsed: WmlParsed) => {
|
||||
return parsed.reduce((acc, [tagName, tagAttrs]) => {
|
||||
if (acc[tagName] === undefined) {
|
||||
acc[tagName] = []
|
||||
}
|
||||
|
||||
acc[tagName].push(tagAttrs)
|
||||
return acc
|
||||
}, {} as { [tagName: string]: Array<{ [attributeName: string]: { translatable: boolean, value: string } }> })
|
||||
}
|
||||
|
||||
export default rootByTagName
|
102
utils/wesnoth-map-diff/tests/arbitraries.ts
Normal file
102
utils/wesnoth-map-diff/tests/arbitraries.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
import * as fc from 'fast-check'
|
||||
|
||||
const keyChars = [
|
||||
...Array.from(new Array(10), (_, index) => String.fromCharCode(48 + index)), // 0-9
|
||||
...Array.from(new Array(24), (_, index) => String.fromCharCode(65 + index)), // A-Z
|
||||
...Array.from(new Array(24), (_, index) => String.fromCharCode(97 + index)), // a-z
|
||||
'_',
|
||||
]
|
||||
|
||||
const valueChars = [
|
||||
...keyChars, '/', '-', '!', '@', '$', '%', '^', '&', '*', '(', ')', '[', ']', ' ',
|
||||
]
|
||||
|
||||
const wmlName = () => fc.stringOf(
|
||||
fc.constantFrom(...keyChars), { minLength: 1 }
|
||||
)
|
||||
|
||||
const wmlValue = () => fc.stringOf(
|
||||
fc.constantFrom(...valueChars), { minLength: 1 }
|
||||
).filter((value) => (value.trimStart().length > 0) && (value.includes('_ ') === false))
|
||||
|
||||
const wmlValueString = () => fc.stringOf(
|
||||
fc.constantFrom(...valueChars, '\n', '"'), { minLength: 1 }
|
||||
).filter((value) => (value.trimStart().length > 0) && (value.includes('_ ') === false)).map((value) => value.replace(/"/g, '""test quote""'))
|
||||
|
||||
const wmlValueTranslatable = (): fc.Arbitrary<string> => {
|
||||
return fc.convertFromNext(
|
||||
fc.convertToNext(wmlValue()).map(
|
||||
(t) => `_ ${t}`,
|
||||
(t) => (t as string).replace('_ ', '')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const fullAttributeUnmapper = (fullAttribute: string) => {
|
||||
const [key, keyPadding, valuePadding, value] = fullAttribute.split(/(\s*)=(\s*)/)
|
||||
return { key, keyPadding: keyPadding.length, valuePadding: valuePadding.length, value }
|
||||
}
|
||||
|
||||
const fullAttributeStringUnmapper = (fullAttributeString: string) => {
|
||||
const [key, keyPadding, valuePadding, rawValue] = fullAttributeString.split(/(\s*)=(\s*)/)
|
||||
|
||||
const value = rawValue.replace(/^"/, '').replace(/"$/, '')
|
||||
|
||||
return { key, keyPadding: keyPadding.length, valuePadding: valuePadding.length, value }
|
||||
}
|
||||
|
||||
const fullAttribute = (): fc.Arbitrary<string> => {
|
||||
return fc.convertFromNext(
|
||||
fc.convertToNext(
|
||||
fc.tuple(
|
||||
wmlName(),
|
||||
fc.nat({ max: 5 }),
|
||||
fc.nat({ max: 5 }),
|
||||
wmlValue()
|
||||
)
|
||||
).map(
|
||||
([key, keyPadding, valuePadding, value]) => `${key}${' '.repeat(keyPadding)}=${' '.repeat(valuePadding)}${value}`,
|
||||
(attribute) => {
|
||||
if (typeof attribute !== 'string') {
|
||||
throw new Error('Invalid type')
|
||||
}
|
||||
|
||||
const segments = fullAttributeUnmapper(attribute)
|
||||
return [segments.key, segments.keyPadding, segments.valuePadding, segments.value]
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const fullAttributeString = (): fc.Arbitrary<string> => {
|
||||
return fc.convertFromNext(
|
||||
fc.convertToNext(
|
||||
fc.tuple(
|
||||
wmlName(),
|
||||
fc.nat({ max: 5 }),
|
||||
fc.nat({ max: 5 }),
|
||||
wmlValueString()
|
||||
)
|
||||
).map(
|
||||
([key, keyPadding, valuePadding, value]) => `${key}${' '.repeat(keyPadding)}=${' '.repeat(valuePadding)}"${value}"`,
|
||||
(attribute) => {
|
||||
if (typeof attribute !== 'string') {
|
||||
throw new Error('Invalid type')
|
||||
}
|
||||
|
||||
const segments = fullAttributeStringUnmapper(attribute)
|
||||
return [segments.key, segments.keyPadding, segments.valuePadding, segments.value]
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
wmlName,
|
||||
wmlValue,
|
||||
wmlValueTranslatable,
|
||||
fullAttributeUnmapper,
|
||||
fullAttribute,
|
||||
fullAttributeString,
|
||||
fullAttributeStringUnmapper,
|
||||
}
|
164
utils/wesnoth-map-diff/tests/parser.test.ts
Normal file
164
utils/wesnoth-map-diff/tests/parser.test.ts
Normal file
|
@ -0,0 +1,164 @@
|
|||
import { testProp, fc } from 'jest-fast-check'
|
||||
import { parseWml } from '../src/wml/parser'
|
||||
import { wmlName, fullAttributeUnmapper, fullAttribute, fullAttributeString, fullAttributeStringUnmapper } from './arbitraries'
|
||||
|
||||
describe('#WML Parser', () => {
|
||||
describe('when source is empty', () => {
|
||||
it('returns empty', () => {
|
||||
const source = ''
|
||||
|
||||
const parsed = parseWml(source)
|
||||
|
||||
expect(parsed).parserGot([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('when tag is empty', () => {
|
||||
testProp('returns an empty object for the tag', [wmlName()], (tagName) => {
|
||||
const source = `
|
||||
[${tagName}]
|
||||
[/${tagName}]
|
||||
`
|
||||
|
||||
const parsed = parseWml(source)
|
||||
|
||||
expect(parsed).parserGot([[tagName, {}]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('when tag has one node', () => {
|
||||
describe('which has a text value', () => {
|
||||
testProp('returns an object with its attributes', [wmlName(), fullAttribute()], (tagName, attribute) => {
|
||||
const source = `
|
||||
[${tagName}]
|
||||
${attribute}
|
||||
[/${tagName}]
|
||||
`
|
||||
|
||||
const parsed = parseWml(source)
|
||||
const segments = fullAttributeUnmapper(attribute)
|
||||
|
||||
expect(parsed).parserGot(
|
||||
[[tagName, { [segments.key]: { translatable: false, value: segments.value.trim() } }]]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('which has a string value', () => {
|
||||
testProp('returns an object with its attributes', [wmlName(), fullAttributeString()], (tagName, attribute) => {
|
||||
const source = `
|
||||
[${tagName}]
|
||||
${attribute}
|
||||
[/${tagName}]
|
||||
`
|
||||
|
||||
const parsed = parseWml(source)
|
||||
const segments = fullAttributeStringUnmapper(attribute)
|
||||
|
||||
expect(parsed).parserGot(
|
||||
[[tagName, { [segments.key]: { translatable: false, value: segments.value } }]]
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there are two tags', () => {
|
||||
describe('with the same name', () => {
|
||||
testProp(
|
||||
'returns both tags with its attributes',
|
||||
[wmlName(), fullAttribute(), fullAttribute()],
|
||||
(tagName, firstAttribute, secondAttribute) => {
|
||||
const source = `
|
||||
[${tagName}]
|
||||
${firstAttribute}
|
||||
[/${tagName}]
|
||||
|
||||
[${tagName}]
|
||||
${secondAttribute}
|
||||
[/${tagName}]
|
||||
`
|
||||
|
||||
const parsed = parseWml(source)
|
||||
const firstSegments = fullAttributeUnmapper(firstAttribute)
|
||||
const secondSegments = fullAttributeUnmapper(secondAttribute)
|
||||
|
||||
expect(parsed).parserGot(
|
||||
[
|
||||
[tagName, { [firstSegments.key]: { translatable: false, value: firstSegments.value.trim() } }],
|
||||
[tagName, { [secondSegments.key]: { translatable: false, value: secondSegments.value.trim() } }],
|
||||
]
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('with different names', () => {
|
||||
testProp(
|
||||
'returns both tags with its attributes',
|
||||
[
|
||||
fc.tuple(wmlName(), wmlName()).filter(([first, second]) => first !== second),
|
||||
fullAttribute(),
|
||||
fullAttribute(),
|
||||
],
|
||||
([firstTagName, secondTagName], firstAttribute, secondAttribute) => {
|
||||
const source = `
|
||||
[${firstTagName}]
|
||||
${firstAttribute}
|
||||
[/${firstTagName}]
|
||||
|
||||
[${secondTagName}]
|
||||
${secondAttribute}
|
||||
[/${secondTagName}]
|
||||
`
|
||||
|
||||
const parsed = parseWml(source)
|
||||
const firstSegments = fullAttributeUnmapper(firstAttribute)
|
||||
const secondSegments = fullAttributeUnmapper(secondAttribute)
|
||||
|
||||
expect(parsed).parserGot(
|
||||
[
|
||||
[firstTagName, { [firstSegments.key]: { translatable: false, value: firstSegments.value.trim() } }],
|
||||
[secondTagName, { [secondSegments.key]: { translatable: false, value: secondSegments.value.trim() } }],
|
||||
]
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when tag is not closed', () => {
|
||||
describe('because of missing the the closed one', () => {
|
||||
testProp('raises an error', [wmlName(), fc.oneof(fullAttribute(), fullAttributeString(), fc.constant(''))], (tagName, tagBody) => {
|
||||
const source = `
|
||||
[${tagName}]
|
||||
${tagBody}
|
||||
`
|
||||
|
||||
const parsed = parseWml(source)
|
||||
|
||||
expect(parsed).parserFailedWith([`Close tag for [${tagName}]`, 'Tag body'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('because of unmatched pair name', () => {
|
||||
testProp(
|
||||
'raises an error',
|
||||
[
|
||||
fc.tuple(wmlName(), wmlName()).filter(([first, second]) => first !== second),
|
||||
fc.oneof(fullAttribute(), fullAttributeString(), fc.constant('')),
|
||||
],
|
||||
([firstTagName, secondTagName], tagBody) => {
|
||||
const source = `
|
||||
[${firstTagName}]
|
||||
${tagBody}
|
||||
[${secondTagName}]
|
||||
`
|
||||
|
||||
const parsed = parseWml(source)
|
||||
|
||||
expect(parsed).parserFailedWith([`Close tag for [${firstTagName}]`, 'Tag body'])
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
86
utils/wesnoth-map-diff/tests/rootByTagName.test.ts
Normal file
86
utils/wesnoth-map-diff/tests/rootByTagName.test.ts
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { testProp, fc } from 'jest-fast-check'
|
||||
import type { WmlParsed } from '../src/wml/parser'
|
||||
import rootByTagName from '../src/wml/rootByTagName'
|
||||
import { wmlName } from './arbitraries'
|
||||
|
||||
describe('#RootByTagName', () => {
|
||||
describe('when there is no tags', () => {
|
||||
it('returns empty', () => {
|
||||
const parsed: WmlParsed = []
|
||||
|
||||
const rooted = rootByTagName(parsed)
|
||||
|
||||
expect(rooted).toStrictEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when has only one tag', () => {
|
||||
testProp(
|
||||
'includes it on the rooted object',
|
||||
[
|
||||
wmlName(),
|
||||
fc.dictionary(wmlName(), fc.record({ translatable: fc.boolean(), value: fc.string() })),
|
||||
],
|
||||
(tagName, attributes) => {
|
||||
const parsed: WmlParsed = [
|
||||
[tagName, attributes],
|
||||
]
|
||||
|
||||
const rooted = rootByTagName(parsed)
|
||||
|
||||
expect(rooted).toStrictEqual({
|
||||
[tagName]: [attributes],
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('when has two tags', () => {
|
||||
describe('with the same name', () => {
|
||||
testProp(
|
||||
'merge it on the rooted object',
|
||||
[
|
||||
wmlName(),
|
||||
fc.dictionary(wmlName(), fc.record({ translatable: fc.boolean(), value: fc.string() })),
|
||||
fc.dictionary(wmlName(), fc.record({ translatable: fc.boolean(), value: fc.string() })),
|
||||
],
|
||||
(tagName, firstAttributes, secondAttributes) => {
|
||||
const parsed: WmlParsed = [
|
||||
[tagName, firstAttributes],
|
||||
[tagName, secondAttributes],
|
||||
]
|
||||
|
||||
const rooted = rootByTagName(parsed)
|
||||
|
||||
expect(rooted).toStrictEqual({
|
||||
[tagName]: [firstAttributes, secondAttributes],
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('with different names', () => {
|
||||
testProp(
|
||||
'includes both on the rooted object',
|
||||
[
|
||||
fc.tuple(wmlName(), wmlName()).filter(([first, second]) => first !== second),
|
||||
fc.dictionary(wmlName(), fc.record({ translatable: fc.boolean(), value: fc.string() })),
|
||||
fc.dictionary(wmlName(), fc.record({ translatable: fc.boolean(), value: fc.string() })),
|
||||
],
|
||||
([firstTagName, secondTagName], firstAttributes, secondAttributes) => {
|
||||
const parsed: WmlParsed = [
|
||||
[firstTagName, firstAttributes],
|
||||
[secondTagName, secondAttributes],
|
||||
]
|
||||
|
||||
const rooted = rootByTagName(parsed)
|
||||
|
||||
expect(rooted).toStrictEqual({
|
||||
[firstTagName]: [firstAttributes],
|
||||
[secondTagName]: [secondAttributes],
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
9
utils/wesnoth-map-diff/tsconfig-dev.json
Normal file
9
utils/wesnoth-map-diff/tsconfig-dev.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"sourceMap": true
|
||||
},
|
||||
"exclude": [
|
||||
"./tests"
|
||||
]
|
||||
}
|
6
utils/wesnoth-map-diff/tsconfig-prod.json
Normal file
6
utils/wesnoth-map-diff/tsconfig-prod.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": [
|
||||
"./tests"
|
||||
]
|
||||
}
|
16
utils/wesnoth-map-diff/tsconfig.json
Normal file
16
utils/wesnoth-map-diff/tsconfig.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2019",
|
||||
"module": "commonjs",
|
||||
"importsNotUsedAsValues": "error",
|
||||
"strict": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"noImplicitAny": false,
|
||||
"outDir": "./build",
|
||||
},
|
||||
}
|
Loading…
Add table
Reference in a new issue