Skip to content

Commit 1848d50

Browse files
committed
Added getCellBoundingBox to find the bounding box of an array of [x, y][] cell positions
Added numberPairArrayToMatrix util function to convert an array of [x, y][] cell positions to a matrix of 0's and 1's Added Set2D data structure from cobyj33/automata Moved ByteArray to core/byteArray.ts Added support for Life 1.06 file format Started adding tests for Life 1.06 file format Added support for plaintext file format Added functionality to writeLife106String to automatically ignore duplicate values Added writePlainTextMetadata to write description data for the plaintext format Added writePlainTextFromCoordinates and writePlainTextMatrix to be able to build the plaintext format from both forms of data
1 parent 9e177ca commit 1848d50

File tree

13 files changed

+471
-14
lines changed

13 files changed

+471
-14
lines changed

core/byteArray.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
2+
3+
export class ByteArray {
4+
private data: number[]
5+
6+
constructor() {
7+
this.data = []
8+
}
9+
10+
getData() : Uint8ClampedArray {
11+
return Uint8ClampedArray.from(this.data)
12+
}
13+
14+
getString(): string {
15+
return this.data.map(val => String.fromCharCode(val)).join("")
16+
}
17+
18+
writeByte(val: number): void {
19+
this.data.push(val)
20+
}
21+
22+
writeUTFBytes(str: string): void {
23+
for (var len = str.length, i = 0; i < len; i++) {
24+
this.writeByte(str.charCodeAt(i))
25+
}
26+
}
27+
28+
writeBytes(array: number[]): void {
29+
array.forEach(num => this.writeByte(num))
30+
}
31+
}

core/set2D.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
export class Set2D {
2+
private map: Map<number, Set<number>> = new Map<number, Set<number>>();
3+
private _length: number;
4+
5+
constructor(values: Array<[number, number]> | Set2D = []) {
6+
this._length = 0;
7+
8+
if (Array.isArray(values)) {
9+
values.forEach(value => this.add(value[0], value[1]));
10+
} else {
11+
values.forEach(([first, second]) => this.add(first, second))
12+
}
13+
}
14+
15+
fullClear() {
16+
this.map = new Map();
17+
this._length = 0;
18+
}
19+
20+
clear() {
21+
[...this.map.values()].forEach(set => set.clear())
22+
this._length = 0;
23+
}
24+
25+
get length(): number { return this._length }
26+
27+
static fromNumberMatrix(values: number[][]): Set2D {
28+
const set = new Set2D();
29+
for (let row = 0; row < values.length; row++) {
30+
for (let col = 0; col < values[row].length; col++) {
31+
if (values[row][col] === 1) {
32+
set.add(row, col)
33+
}
34+
}
35+
}
36+
return set;
37+
}
38+
39+
getTuples(): Array<[number, number]> {
40+
const arr = new Array<[number, number]>(this.length)
41+
let i = 0;
42+
this.forEach((pair) => {
43+
arr[i] = pair
44+
i++;
45+
})
46+
47+
return arr
48+
}
49+
50+
forEach(callbackfn: (curr: [number, number]) => void) {
51+
this.map.forEach((set, first) => set.forEach(second => callbackfn([first, second]) ))
52+
}
53+
54+
55+
add(first: number, second: number): void {
56+
if (this.map.get(first)?.has(second) === false) {
57+
this.map.get(first)?.add(second);
58+
this._length += 1;
59+
} else if (this.map.has(first) === false) {
60+
this.map.set(first, new Set<number>([second]))
61+
this._length += 1;
62+
}
63+
64+
}
65+
66+
remove(first: number, second: number): void {
67+
let set: Set<number> | undefined
68+
if (set = this.map.get(first)) {
69+
if (set.has(second)) {
70+
set.delete(second)
71+
this._length -= 1;
72+
// if (set.size === 0) {
73+
// this.map.delete(first)
74+
// }
75+
}
76+
}
77+
}
78+
79+
has(first: number, second: number): boolean {
80+
return this.map.get(first)?.has(second) || false;
81+
}
82+
83+
hasAll(tuples: Array<[number, number]>): boolean {
84+
return tuples.every(tuple => this.has(tuple[0], tuple[1]));
85+
}
86+
87+
hasAllExact(tuples: Array<[number, number]>): boolean {
88+
return tuples.length === this.length && this.hasAll(tuples);
89+
}
90+
91+
combine(...others: Set2D[]): Set2D {
92+
const set = new Set2D();
93+
set.push(this, ...others);
94+
return set;
95+
}
96+
97+
push(...others: Set2D[]): void {
98+
others.forEach(other => other.forEach(tuple => this.add(tuple[0], tuple[1])) );
99+
}
100+
101+
*[Symbol.iterator](): IterableIterator<[number, number]> {
102+
for (const pair of this.map) {
103+
for (const second of pair[1]) {
104+
yield [pair[0], second]
105+
}
106+
}
107+
}
108+
109+
equals(other: Set2D): boolean {
110+
if (this.length !== other.length) {
111+
return false;
112+
}
113+
114+
for ( const entry of this ) {
115+
if (other.has(entry[0], entry[1]) === false) {
116+
return false;
117+
}
118+
}
119+
return true;
120+
}
121+
}

core/util.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,69 @@ export function isRectangularMatrix<T>(matrix: T[][]): boolean {
1717
}
1818
return true;
1919
}
20+
21+
const ASCII_CHAR_CODE_0 = 48;
22+
const ASCII_CHAR_CODE_9 = 57;
23+
24+
export function isDigit(digit: string): boolean {
25+
return digit.length === 1 && digit.charCodeAt(0) >= 48 && digit.charCodeAt(0) <= 57
26+
}
27+
28+
export function isIntegerString(num: string): boolean {
29+
for (let i = 0; i < num.length; i++) {
30+
if (i === 0) {
31+
if (!isDigit(num[i]) || num[i] === "-") {
32+
return false;
33+
}
34+
} else {
35+
if (!isDigit(num[i])) {
36+
return false;
37+
}
38+
}
39+
}
40+
return true;
41+
}
42+
43+
export function isNumberPairArray(num: number[][]): num is [number, number][] {
44+
return num.every(row => row.length === 2)
45+
}
46+
47+
type Bounds = { x: number, y: number, width: number, height: number }
48+
49+
/**
50+
*
51+
* @param positions
52+
* @returns Bounds object (maxY is given with the basis that positive is up)
53+
*/
54+
export function getCellBoundingBox(positions: [number, number][]): Bounds {
55+
if (positions.length === 0) {
56+
throw new Error("Cannot create bounding box over empty area");
57+
}
58+
59+
let minY = positions[0][1];
60+
let maxY = positions[0][1];
61+
let minX = positions[0][0];
62+
let maxX = positions[0][0];
63+
64+
for (let i = 0; i < positions.length; i++) {
65+
minY = Math.min(minY, positions[i][1])
66+
minX = Math.min(minX, positions[i][0])
67+
maxY = Math.max(maxY, positions[i][1])
68+
maxX = Math.max(maxX, positions[i][0])
69+
}
70+
71+
return { x: minX, y: maxY, width: Math.abs(maxX - minX) + 1, height: Math.abs(maxY - minY) + 1 }
72+
}
73+
74+
export function numberPairArrayToMatrix(positions: [number, number][]): (0 | 1)[][] {
75+
if (positions.length === 0) {
76+
return [];
77+
}
78+
79+
const bounds = getCellBoundingBox(positions);
80+
const matrix: (0 | 1)[][] = Array.from({ length: bounds.height }, () => new Array<0>(bounds.width).fill(0));
81+
positions.forEach(position => {
82+
matrix[bounds.y - position[1]][position[0] - bounds.x] = 1;
83+
})
84+
return matrix;
85+
}

formats/life105.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Life 1.05 File Format Spec: https://conwaylife.com/wiki/Life_1.05
22

3+
import { ByteArray } from "../core/byteArray";
4+
35
const LIFE_105_HEADER = "#Life 1.05" as const
46
const MAX_DESCRIPTION_LINE_COUNT = 22 as const
57
const LIFE_105_MAX_LINE_LENGTH = 80 as const
@@ -14,7 +16,9 @@ interface Life105Config {
1416
}
1517

1618
export function writeLife105File(pattern: (0 | 1)[][], config: Life105Config): string {
17-
return ""
19+
const fileData = new ByteArray()
20+
fileData.writeUTFBytes(LIFE_105_HEADER + "\n");
21+
return fileData.getString();
1822
}
1923

2024
export function readLife105File(): (0 | 1)[][] {

formats/life106.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { ByteArray } from "../core/byteArray"
2+
import { isIntegerString } from "../core/util"
3+
import { Set2D } from "../core/set2D"
4+
5+
const LIFE_106_HEADER = "#Life 1.06" as const
6+
const LIFE_106_FILE_EXTENSIONS = [".lif", ".life"] as const
7+
8+
export function writeLife106String(data: [number, number][]): string {
9+
const byteArray = new ByteArray()
10+
byteArray.writeUTFBytes(LIFE_106_HEADER + "\n")
11+
const dupSet: Set2D = new Set2D();
12+
13+
for (let i = 0; i < data.length; i++) {
14+
const [x, y] = data[i];
15+
if (dupSet.has(x, y)) {
16+
continue;
17+
}
18+
19+
byteArray.writeUTFBytes(`${x} ${y}\n`)
20+
dupSet.add(x, y)
21+
}
22+
23+
return byteArray.getString();
24+
}
25+
26+
export function readLife106String(str: string): [number, number][] {
27+
if (str.startsWith(LIFE_106_HEADER + "\n")) {
28+
const output: [number, number][] = []
29+
const lines = str.split("\n")
30+
let ended: boolean = false;
31+
32+
for (let i = 1; i < lines.length; i++) {
33+
const nums = lines[i].trim().split(" ")
34+
if (nums.length !== 2) {
35+
if (nums.length === 0 || nums.every(val => val.length === 0)) {
36+
ended = true;
37+
continue;
38+
}
39+
throw new Error(`Invalid Life 1.06 string. Error at Line ${i}. There must be an X and a Y position only on subsequent lines after the Life 1.06 Header \n${str}\n `)
40+
}
41+
42+
if (ended) {
43+
throw new Error(`Invalid Life 1.06 string: \n${str}\n Error at Line ${i}. X and Y Values must be on subsequent lines`)
44+
}
45+
46+
if (isIntegerString(nums[0]) && isIntegerString(nums[1])) {
47+
output.push([Number.parseInt(nums[0]), Number.parseInt(nums[1])])
48+
} else {
49+
throw new Error(`Invalid Life 1.06 string. Error at Line ${i}. Cell positions must be integers ( got ${nums[0]} and ${nums[1]}) \n${str}\n `)
50+
}
51+
}
52+
53+
return output;
54+
} else {
55+
throw new Error(`Could not read Life 1.06 string. Error at Line 1: does not begin with appropriate header. Must be "${LIFE_106_HEADER}" ${str}`)
56+
}
57+
}

formats/plaintext.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { ByteArray } from "../core/byteArray"
2+
import { numberPairArrayToMatrix } from "../core/util"
3+
4+
const PLAIN_TEXT_HEADER_BEGINNING = "!Name: "
5+
6+
function writePlainTextMetadata(byteArray: ByteArray, name: string, description: string | string[]): void {
7+
byteArray.writeUTFBytes(PLAIN_TEXT_HEADER_BEGINNING + name + "\n")
8+
if (description.length > 0) {
9+
if (typeof(description) === "string") {
10+
const lines = description.split("\n")
11+
lines.forEach(line => byteArray.writeUTFBytes(`!${line}\n`))
12+
} else {
13+
const lines = description.flatMap(lines => lines.split("\n"))
14+
lines.forEach(line => byteArray.writeUTFBytes(`!${line}\n`))
15+
}
16+
}
17+
18+
byteArray.writeUTFBytes("!\n")
19+
}
20+
21+
export function writePlainTextFromCoordinates(positions: [number, number][], name: string, description: string | string[]): string {
22+
return writePlainTextMatrix(numberPairArrayToMatrix(positions) as (0 | 1)[][], name, description)
23+
}
24+
25+
export function writePlainTextMatrix(data: (0 | 1)[][], name: string, description: string | string[]): string {
26+
const byteArray = new ByteArray();
27+
writePlainTextMetadata(byteArray, name, description)
28+
29+
const height = data.length;
30+
const width = Math.max(...data.map(row => row.length))
31+
32+
for (let row = 0; row < height; row++) {
33+
for (let col = 0; col < width; col++) {
34+
if (col >= data[row].length) {
35+
byteArray.writeUTFBytes(".");
36+
} else {
37+
byteArray.writeUTFBytes(data[row][col] === 0 ? "." : "O");
38+
}
39+
}
40+
byteArray.writeUTFBytes("\n")
41+
}
42+
43+
return byteArray.getString();
44+
}

formats/rule.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11

22

3+
type LifeRuleData = { birth: number[], survival: number[] }
34

45
export function isValidLifeString(lifeString: string, errorOutput?: (error: string) => any) {
56
const error = getLifeStringError(lifeString);

index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1-
import { hello } from "./formats/life105"
1+
import { readLife106String, writeLife106String } from "./formats/life106"
2+
import { writePlainTextFromCoordinates, writePlainTextMatrix } from "./formats/plaintext"
23

3-
console.log("run", hello())
4+
const life106String = writeLife106String([[0, 1], [1, 2], [2, 3], [3, 4]])
5+
console.log(life106String)
6+
console.log(readLife106String(life106String))
7+
8+
const plainTextString = writePlainTextFromCoordinates([[0, 1], [1, 2], [2, 3], [3, 4]], "Steps", "")
9+
console.log(plainTextString)

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"@types/jest": "^29.4.0",
88
"babel-jest": "^29.4.3",
99
"jest": "^29.4.3",
10-
"ts-node": "^10.9.1"
10+
"ts-node": "^10.9.1",
11+
"typescript": "^4.9.5"
1112
},
1213
"scripts": {
1314
"test": "jest"

tests/data/life106/acorn.lif

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#Life 1.06
2+
0 0
3+
1 0
4+
2 0

0 commit comments

Comments
 (0)