{"id":41624392,"url":"https://github.com/alvaro-cuesta/townsclipper","last_synced_at":"2026-01-24T14:12:09.425Z","repository":{"id":46020342,"uuid":"290254066","full_name":"alvaro-cuesta/townsclipper","owner":"alvaro-cuesta","description":"Townscaper clipboard data format converter.","archived":false,"fork":false,"pushed_at":"2020-09-30T12:01:40.000Z","size":3101,"stargazers_count":33,"open_issues_count":0,"forks_count":2,"subscribers_count":2,"default_branch":"master","last_synced_at":"2025-08-16T20:14:59.952Z","etag":null,"topics":["clipboard","clipboard-format","converter","decoder","format-converter","save-editor","townscaper"],"latest_commit_sha":null,"homepage":"","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/alvaro-cuesta.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2020-08-25T15:35:06.000Z","updated_at":"2025-01-17T19:05:56.000Z","dependencies_parsed_at":"2022-07-18T06:00:37.683Z","dependency_job_id":null,"html_url":"https://github.com/alvaro-cuesta/townsclipper","commit_stats":null,"previous_names":["alvaro-cuesta/townscaper-clipboard"],"tags_count":8,"template":false,"template_full_name":null,"purl":"pkg:github/alvaro-cuesta/townsclipper","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alvaro-cuesta%2Ftownsclipper","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alvaro-cuesta%2Ftownsclipper/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alvaro-cuesta%2Ftownsclipper/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alvaro-cuesta%2Ftownsclipper/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/alvaro-cuesta","download_url":"https://codeload.github.com/alvaro-cuesta/townsclipper/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alvaro-cuesta%2Ftownsclipper/sbom","scorecard":{"id":187477,"data":{"date":"2025-08-11","repo":{"name":"github.com/alvaro-cuesta/townsclipper","commit":"8914b9d5921d5b18cb8174c6aed4743b3e883876"},"scorecard":{"version":"v5.2.1-40-gf6ed084d","commit":"f6ed084d17c9236477efd66e5b258b9d4cc7b389"},"score":1.7,"checks":[{"name":"Packaging","score":-1,"reason":"packaging workflow not detected","details":["Warn: no GitHub/GitLab publishing workflow detected."],"documentation":{"short":"Determines if the project is published as a package that others can easily download, install, easily update, and uninstall.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#packaging"}},{"name":"Code-Review","score":0,"reason":"Found 0/30 approved changesets -- score normalized to 0","details":null,"documentation":{"short":"Determines if the project requires human code review before pull requests (aka merge requests) are merged.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#code-review"}},{"name":"Maintained","score":0,"reason":"0 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 0","details":null,"documentation":{"short":"Determines if the project is \"actively maintained\".","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#maintained"}},{"name":"Token-Permissions","score":-1,"reason":"No tokens found","details":null,"documentation":{"short":"Determines if the project's workflows follow the principle of least privilege.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#token-permissions"}},{"name":"Dangerous-Workflow","score":-1,"reason":"no workflows found","details":null,"documentation":{"short":"Determines if the project's GitHub Action workflows avoid dangerous patterns.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#dangerous-workflow"}},{"name":"SAST","score":0,"reason":"no SAST tool detected","details":["Warn: no pull requests merged into dev branch"],"documentation":{"short":"Determines if the project uses static code analysis.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#sast"}},{"name":"Binary-Artifacts","score":10,"reason":"no binaries found in the repo","details":null,"documentation":{"short":"Determines if the project has generated executable (binary) artifacts in the source repository.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#binary-artifacts"}},{"name":"CII-Best-Practices","score":0,"reason":"no effort to earn an OpenSSF best practices badge detected","details":null,"documentation":{"short":"Determines if the project has an OpenSSF (formerly CII) Best Practices Badge.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#cii-best-practices"}},{"name":"Pinned-Dependencies","score":-1,"reason":"no dependencies found","details":null,"documentation":{"short":"Determines if the project has declared and pinned the dependencies of its build process.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#pinned-dependencies"}},{"name":"Security-Policy","score":0,"reason":"security policy file not detected","details":["Warn: no security policy file detected","Warn: no security file to analyze","Warn: no security file to analyze","Warn: no security file to analyze"],"documentation":{"short":"Determines if the project has published a security policy.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#security-policy"}},{"name":"Fuzzing","score":0,"reason":"project is not fuzzed","details":["Warn: no fuzzer integrations found"],"documentation":{"short":"Determines if the project uses fuzzing.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#fuzzing"}},{"name":"License","score":10,"reason":"license file detected","details":["Info: project has a license file: LICENSE:0","Info: FSF or OSI recognized license: MIT License: LICENSE:0"],"documentation":{"short":"Determines if the project has defined a license.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#license"}},{"name":"Signed-Releases","score":-1,"reason":"no releases found","details":null,"documentation":{"short":"Determines if the project cryptographically signs release artifacts.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#signed-releases"}},{"name":"Branch-Protection","score":0,"reason":"branch protection not enabled on development/release branches","details":["Warn: branch protection not enabled for branch 'master'"],"documentation":{"short":"Determines if the default and release branches are protected with GitHub's branch protection settings.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#branch-protection"}},{"name":"Vulnerabilities","score":0,"reason":"28 existing vulnerabilities detected","details":["Warn: Project is vulnerable to: GHSA-968p-4wvh-cqc8","Warn: Project is vulnerable to: GHSA-67hx-6x53-jw92","Warn: Project is vulnerable to: GHSA-93q8-gq69-wqmw","Warn: Project is vulnerable to: GHSA-v6h2-p8h4-qcjw","Warn: Project is vulnerable to: GHSA-grv7-fg5c-xmjg","Warn: Project is vulnerable to: GHSA-3xgq-45jj-v275","Warn: Project is vulnerable to: GHSA-gxpj-cx7g-858c","Warn: Project is vulnerable to: GHSA-w573-4hg7-7wgq","Warn: Project is vulnerable to: GHSA-fjxv-7rqg-78g4","Warn: Project is vulnerable to: GHSA-43f8-2h32-f4cj","Warn: Project is vulnerable to: GHSA-896r-f27r-55mw","Warn: Project is vulnerable to: GHSA-9c47-m6qq-7p4h","Warn: Project is vulnerable to: GHSA-29mw-wpgm-hmr9","Warn: Project is vulnerable to: GHSA-35jh-r3h4-6jhm","Warn: Project is vulnerable to: GHSA-952p-6rrq-rcjv","Warn: Project is vulnerable to: GHSA-f8q6-p94x-37v3","Warn: Project is vulnerable to: GHSA-xvch-5gv4-984h","Warn: Project is vulnerable to: GHSA-5fw9-fq32-wv5p","Warn: Project is vulnerable to: GHSA-hj48-42vr-x3v9","Warn: Project is vulnerable to: GHSA-hrpp-h998-j3pp","Warn: Project is vulnerable to: GHSA-p8p7-x288-28g6","Warn: Project is vulnerable to: GHSA-c2qf-rxjj-qqgw","Warn: Project is vulnerable to: GHSA-jgrx-mgxx-jf9v","Warn: Project is vulnerable to: GHSA-72xf-g2v4-qvf3","Warn: Project is vulnerable to: GHSA-j8xg-fqg3-53r7","Warn: Project is vulnerable to: GHSA-6fc8-4gx4-v693","Warn: Project is vulnerable to: GHSA-3h5v-q93c-6h6q","Warn: Project is vulnerable to: GHSA-c4w7-xm78-47vh"],"documentation":{"short":"Determines if the project has open, known unfixed vulnerabilities.","url":"https://github.com/ossf/scorecard/blob/f6ed084d17c9236477efd66e5b258b9d4cc7b389/docs/checks.md#vulnerabilities"}}]},"last_synced_at":"2025-08-16T20:03:47.337Z","repository_id":46020342,"created_at":"2025-08-16T20:03:47.337Z","updated_at":"2025-08-16T20:03:47.337Z"},"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":28729411,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-01-24T10:24:43.181Z","status":"ssl_error","status_checked_at":"2026-01-24T10:24:36.112Z","response_time":89,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["clipboard","clipboard-format","converter","decoder","format-converter","save-editor","townscaper"],"created_at":"2026-01-24T14:12:08.932Z","updated_at":"2026-01-24T14:12:09.419Z","avatar_url":"https://github.com/alvaro-cuesta.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Townsclipper [![NPM latest version](https://img.shields.io/npm/v/townsclipper.svg)](https://npmjs.com/package/townsclipper)\r\n\r\n[Townscaper](https://store.steampowered.com/app/1291340/Townscaper/) clipboard data format converter.\r\n\r\nEncodes and decodes towns in clipboard (text) into various other formats. For example, it can turn\r\nthis...\r\n\r\n```\r\nFC5ADI3TQN\r\n```\r\n\r\n\u003cp align=\"center\"\u003e\r\n\u003ca href=\"./media/FC5ADI3TQN.png\"\u003e\r\n\u003cimg src=\"./media/FC5ADI3TQN.png\" alt=\"FC5ADI3TQN screenshot\"\u003e\r\n\u003c/a\u003e\r\n\u003c/p\u003e\r\n\r\n...into this...\r\n\r\n```json\r\n[\r\n  {\r\n    \"x\": -9,\r\n    \"y\": 9,\r\n    \"voxels\": {\r\n      \"0\": 15,\r\n      \"1\": 0\r\n    }\r\n  },\r\n  {\r\n    \"x\": 0,\r\n    \"y\": 0,\r\n    \"voxels\": {\r\n      \"1\": 0,\r\n      \"2\": 0\r\n    }\r\n  }\r\n]\r\n```\r\n\r\n...and back again!\r\n\r\n- [Concepts](#concepts)\r\n- [CLI](#cli)\r\n- [Library](#library)\r\n- [Contributing](#contributing)\r\n\r\n## Concepts\r\n\r\n- **Voxels:** Each little cube that we build the town with.\r\n\r\n- **Types:** The different colors of voxels. Also empty (air) and ground.\r\n\r\n- **Corners:** Each possible construction point in the grid. Contains a column of voxels (or empty\r\n  space) in a specific X and Y coordinate.\r\n\r\n- **Clip string:** Clipboard save string, as generated when clicking the \"Save to Clipboard\"\r\n  button in Townscaper.\r\n\r\n- **Bit string:** Raw binary data decoded from clip strings.\r\n\r\n- **Dense Representation:** A JSON representation of the data inside a clip string. Some details\r\n  are abstracted away since they can be calculated.\r\n\r\n  Although multiple DRs can generate the same bit string (and vice versa) they can be transformed\r\n  univocally as long as the least number of bits is used.\r\n\r\n  Internally:\r\n\r\n  - Corner positions are represented as deltas (`x`) and offsets (`y`).\r\n  - Voxels are represented as a dense array of types (column in that corner) from lowest to highest.\r\n\r\n   ```ts\r\n   type Dense = {\r\n     // The initial X and Y coordinates from corners.\r\n     xInitial: number,\r\n     yInitial: number,\r\n     // Up to 15 colors. Absolute voxel types (0 = red, 14 = white, 15 = ground).\r\n     // This is used as a lookup table later in DenseCorner's `voxels`.\r\n     types: number[],\r\n     // See below.\r\n     corners: DenseCorner[],\r\n   }\r\n\r\n   type DenseCorner =  {\r\n     // How much to advance `X` from last corner. Must be `null` on the first `Corner`.\r\n     xDelta: number | null,\r\n     // yPosition = yInitial + yOffset.\r\n     yOffset: number,\r\n     // Whether this corner's column has a ground voxel.\r\n     hasGround: bool,\r\n     // Index in `types` lookup table. `-1` for empty.\r\n     voxels: number[],\r\n   }\r\n   ```\r\n\r\n- **Scape files:** Towns as stored in disk, in plain XML format. In Windows they are stored as a\r\n  bunch of `Town*.scape` files in `%appdata%\\..\\LocalLow\\Oskar Stalberg\\Townscaper\\` (i.e.\r\n  `C:\\Users\\\u003cusername\u003e\\AppData\\LocalLow\\Oskar Stalberg\\Townscaper\\`). Chris Love has\r\n  [a great writeup](https://medium.com/@chrisluv/getting-hacky-with-townscaper-5a31cf7f4c6a) about\r\n  these.\r\n\r\n  Long story short: they are a list of corners `(x, y, number of voxels)` and another list of\r\n  voxels `(height, type)` in order of appearance in corners.\r\n\r\n- **Sparse Representation:** JSON representation with voxels in sparse form (similar to Scape\r\n  files).\r\n\r\n   ```ts\r\n   type Sparse = SparseCorner[]\r\n\r\n   type SparseCorner = {\r\n     // Absolute position\r\n     x: number,\r\n     y: number,\r\n     // Map from height (0 = ground) to absolute voxel type (0 = red, 14 = white, 15 = ground)\r\n     voxels: { [height: number] : number },\r\n   }\r\n   ```\r\n\r\n## CLI\r\n\r\nInstallation requires [Node.js](https://nodejs.org) (latest LTS should work) and NPM or Yarn.\r\nFirst install globally (you might need root/admin permissions):\r\n\r\n```sh\r\nnpm install -g townsclipper\r\n# Or with Yarn\r\nyarn global add townsclipper\r\n```\r\n\r\nIt will install the `townsc` command-line tool. Use `townsc --help` for instructions.\r\n\r\nYou can also use it locally without `-g` or `global`, but you'll have to run it in that specific\r\nfolder via `./node_modules/.bin/townsc.js`.\r\n\r\n### Examples\r\n\r\n1. **Inspect a save string as dense representation**\r\n\r\n   ```sh\r\n   townsc clip dense --pretty ASJAJ6Za1TAa\r\n   ```\r\n\r\n2. **Same, but reading from a file (or any stdin for that matter)**\r\n\r\n   ```sh\r\n   cat myFile | townsc clip dense --pretty\r\n   ```\r\n\r\n3. **Replace red blocks with blue**\r\n\r\n   ```sh\r\n   townsc clip dense ASJAJ6Za1TAa |\r\n   sed 's/\"types\":\\[0]/\"types\":\\[9]/' |\r\n   townsc dense clip\r\n   ```\r\n\r\n4. **Edit savestring data as sparse in Vim**\r\n\r\n   ```sh\r\n   tmpfile=$(mktemp) \u0026\u0026\r\n   townsc clip sparse --pretty ASJAJ6Za1TAa \u003e $tmpfile \u0026\u0026\r\n   vim $tmpfile \u0026\u0026\r\n   cat $tmpfile | townsc sparse clip \u0026\u0026\r\n   rm $tmpfile\r\n   ```\r\n\r\n   (TIP: Make a shell alias out of this!)\r\n\r\n## Library\r\n\r\nYou can programatically use Townsclipper as a library (both NodeJS and browser via bundler should\r\nwork). See [`./lib/index.js`](./lib/index.js).\r\n\r\n```\r\nnpm install --save townsclipper\r\n# Or with Yarn\r\nyarn add townsclipper\r\n```\r\n\r\n## Contributing [![MIT License](https://img.shields.io/npm/l/townsclipper)](./LICENSE)\r\n\r\nJust test the CLI and library! Use it! Break it! Enjoy it! And\r\n[report any bugs](https://github.com/alvaro-cuesta/townsclipper/issues) :P\r\n\r\nPull requests are welcome. [Fork](https://github.com/alvaro-cuesta/townsclipper/network/members)\r\nthis repository! For now we're just working on `master` since were aren't even v1.0.\r\n\r\n**Do a global search for `TODO` to see what's pending to do or discuss.**\r\n\r\n#### Rules:\r\n\r\n- Clipboard strings should match Townscaper's. If you can make them shorter, hide the behavior\r\n  behind a flag (but make it on by default!)\r\n\r\n- JSON string output doesn't have to match exactly as long as it parses the same.\r\n\r\n- Be explicit in Dense Representation. I'd rather have a `null` value than a missing key (see\r\n  `xDelta`).\r\n\r\n- When in doubt, do what Townscaper does.\r\n\r\n- `yarn test` shouldn't break, and you should add the relevant tests (if any) to your changes.\r\n\r\n- `yarn test:diff` outputs a test set into the `test_out` folder. Inspect differences (if any) in\r\n  `git diff` to check that everything is working as intended. Feel free to add tests cases if\r\n  needed. Remember to commit `test_out` so we can use `git diff` with your changes!\r\n\r\n### TODO\r\n\r\n- The grid is irregular and we need it if we want to generate valid coordinates. Chris Love has\r\n  done some research on this ([Twitter thread](https://twitter.com/ChrisLuv/status/1280594189412073474)).\r\n\r\n  Even though eventually the grid size is locked when you build enough, it's actually infinite. To\r\n  see it in action, scroll before building any blocks.\r\n\r\n  Some people have shared filled patches, and the code in http://scriptology.de/townscaper.html\r\n  has a list of valid corners, but they are just a limited set. This library\r\n  [used to have a `VALID_CORNERS` list too](https://github.com/alvaro-cuesta/townsclipper/blob/bf646487f3efb7dfa0566d130db60d0ccdbfb27b/lib/valid-corners.js)\r\n  but it was from a specific filled patch (before I realized the grid limits were arbitrary and\r\n  procedurally infinite).\r\n\r\n  I'd rather not encourage using a specific patch, hence why I removed the `VALID_CORNERS`. For now\r\n  you'll have to generate your own list of valid corners if needed, but ideally we'd understand the\r\n  algorithm.\r\n\r\n  See https://www.youtube.com/watch?v=1hqt8JkYRdI for more info.\r\n\r\n  - Maybe use this to check if corners are valid in dense and sparse (from and to)?\r\n\r\n- Test: `denseToBits` missing cases\r\n\r\n- Maybe don't implicitly remove empty corners and warn intead? Applies to various conversions in\r\n  dense, sparse, bits.\r\n\r\n  - This might require having a warning system versus just throwing.\r\n\r\n- Maybe be more lenient with input as long as it doesn't break output (e.g. from dense to sparse,\r\n  why not accept too many `types`?) On the other hand, it might help catch bugs.\r\n\r\n  More instances: accept `0` or `undefined` when expecting `null`, etc.\r\n\r\n- Online version for people that can't/won't use the CLI.\r\n\r\n- Some kind of random generator to showcase the possibilities.\r\n\r\n- /dev/clipboard example (what about MINGW64? I've been unable to make Node's stdout work)\r\n\r\n- Move `test:diff` to Jest\r\n\r\n- Remove trailing `-1`s in `bitsToDense` and `denseToBits`?\r\n\r\n  - Can of worms? It would make sense to also do things like sort types in Townscaper order\r\n\r\n  - OTOH we're already removing empty corners\r\n\r\n  - Annotate might already be broken since `bitsToDense` might lose information\r\n\r\n  - Maybe hide behind `--optimize` flag, or `--no-optimize`\r\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Falvaro-cuesta%2Ftownsclipper","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Falvaro-cuesta%2Ftownsclipper","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Falvaro-cuesta%2Ftownsclipper/lists"}