Add offline cosmic black hole d6 theme
This commit is contained in:
1 parent
a931d9f19a
commit
1391f5b773
19 files changed
+752
-10
No files matched your search
@@ -0,0 +1,23 @@
|
||||
The d6 collider, transform and face map are derived from @3d-dice/dice-box 1.1.4.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 3Ddice
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
{"producer":{"name":"21 cosmic theme generator","version":"1","file":"cosmic-black-hole.json"},"meshes":[{"name":"d6","id":"d6","position":[-0.6,0,0],"rotationQuaternion":[0,0,0,-1],"scaling":[1,1,1],"isVisible":true,"isEnabled":true,"pickable":false,"positions":[0,0.046,0,0.0088017,0.0451161,0.0017508,0.0089742,0.0451161,0,0,0.046,0,0.008291,0.0451161,0.0034343,0.0088017,0.0451161,0.0017508,0,0.046,0,0.0074617,0.0451161,0.0049858,0.008291,0.0451161,0.0034343,0,0.046,0,0.0063457,0.0451161,0.0063457,0.0074617,0.0451161,0.0049858,0,0.046,0,0.0049858,0.0451161,0.0074617,0.0063457,0.0451161,0.0063457,0,0.046,0,0.0034343,0.0451161,0.008291,0.0049858,0.0451161,0.0074617,0,0.046,0,0.0017508,0.0451161,0.0088017,0.0034343,0.0451161,0.008291,0,0.046,0,0,0.0451161,0.0089742,0.0017508,0.0451161,0.0088017,0,0.046,0,-0.0017508,0.0451161,0.0088017,0,0.0451161,0.0089742,0,0.046,0,-0.0034343,0.0451161,0.008291,-0.0017508,0.0451161,0.0088017,0,0.046,0,-0.0049858,0.0451161,0.0074617,-0.0034343,0.0451161,0.008291,0,0.046,0,-0.0063457,0.0451161,0.0063457,-0.0049858,0.0451161,0.0074617,0,0.046,0,-0.0074617,0.0451161,0.0049858,-0.0063457,0.0451161,0.0063457,0,0.046,0,-0.008291,0.0451161,0.0034343,-0.0074617,0.0451161,0.0049858,0,0.046,0,-0.0088017,0.0451161,0.0017508,-0.008291,0.0451161,0.0034343,0,0.046,0,-0.0089742,0.0451161,0,-0.0088017,0.0451161,0.0017508,0,0.046,0,-0.0088017,0.0451161,-0.0017508,-0.0089742,0.0451161,0,0,0.046,0,-0.008291,0.0451161,-0.0034343,-0.0088017,0.0451161,-0.0017508,0,0.046,0,-0.0074617,0.0451161,-0.0049858,-0.008291,0.0451161,-0.0034343,0,0.046,0,-0.0063457,0.0451161,-0.0063457,-0.0074617,0.0451161,-0.0049858,0,0.046,0,-0.0049858,0.0451161,-0.0074617,-0.0063457,0.0451161,-0.0063457,0,0.046,0,-0.0034343,0.0451161,-0.008291,-0.0049858,0.0451161,-0.0074617,0,0.046,0,-0.0017508,0.0451161,-0.0088017,-0.0034343,0.0451161,-0.008291,0,0.046,0,0,0.0451161,-0.0089742,-0.0017508,0.0451161,-0.0088017,0,0.046,0,0.0017508,0.0451161,-0.0088017,0,0.0451161,-0.0089742,0,0.046,0,0.0034343,0.0451161,-0.008291,0.0017508,0.0451161,-0.0088017,0,0.046,0,0.0049858,0.0451161,-0.0074617,0.0034343,0.0451161,-0.008291,0,0.046,0,0.0063457,0.0451161,-0.0063457,0.0049858,0.0451161,-0.0074617,0,0.046,0,0.0074617,0.0451161,-0.0049858,0.0063457,0.0451161,-0.0063457,0,0.046,0,0.008291,0.0451161,-0.0034343,0.0074617,0.0451161,-0.0049858,0,0.046,0,0.0088017,0.0451161,-0.0017508,0.008291,0.0451161,-0.0034343,0,0.046,0,0.0089742,0.0451161,0,0.0088017,0.0451161,-0.0017508,0.0089742,0.0451161,0,0.0172652,0.0424985,0.0034343,0.0176034,0.0424985,0,0.0089742,0.0451161,0,0.0088017,0.0451161,0.0017508,0.0172652,0.0424985,0.0034343,0.0088017,0.0451161,0.0017508,0.0162635,0.0424985,0.0067365,0.0172652,0.0424985,0.0034343,0.0088017,0.0451161,0.0017508,0.008291,0.0451161,0.0034343,0.0162635,0.0424985,0.0067365,0.008291,0.0451161,0.0034343,0.0146367,0.0424985,0.0097799,0.0162635,0.0424985,0.0067365,0.008291,0.0451161,0.0034343,0.0074617,0.0451161,0.0049858,0.0146367,0.0424985,0.0097799,0.0074617,0.0451161,0.0049858,0.0124475,0.0424985,0.0124475,0.0146367,0.0424985,0.0097799,0.0074617,0.0451161,0.0049858,0.0063457,0.0451161,0.0063457,0.0124475,0.0424985,0.0124475,0.0063457,0.0451161,0.0063457,0.0097799,0.0424985,0.0146367,0.0124475,0.0424985,0.0124475,0.0063457,0.0451161,0.0063457,0.0049858,0.0451161,0.0074617,0.0097799,0.0424985,0.0146367,0.0049858,0.0451161,0.0074617,0.0067365,0.0424985,0.0162635,0.0097799,0.0424985,0.0146367,0.0049858,0.0451161,0.0074617,0.0034343,0.0451161,0.008291,0.0067365,0.0424985,0.0162635,0.0034343,0.0451161,0.008291,0.0034343,0.0424985,0.0172652,0.0067365,0.0424985,0.0162635,0.0034343,0.0451161,0.008291,0.0017508,0.0451161,0.0088017,0.0034343,0.0424985,0.0172652,0.0017508,0.0451161,0.0088017,0,0.0424985,0.0176034,0.0034343,0.0424985,0.0172652,0.0017508,0.0451161,0.0088017,0,0.0451161,0.0089742,0,0.0424985,0.0176034,0,0.0451161,0.0089742,-0.0034343,0.0424985,0.0172652,0,0.0424985,0.0176034,0,0.0451161,0.0089742,-0.0017508,0.0451161,0.0088017,-0.0034343,0.0424985,0.0172652,-0.0017508,0.0451161,0.0088017,-0.0067365,0.0424985,0.0162635,-0.0034343,0.0424985,0.0172652,-0.0017508,0.0451161,0.0088017,-0.0034343,0.0451161,0.008291,-0.0067365,0.0424985,0.0162635,-0.0034343,0.0451161,0.008291,-0.0097799,0.0424985,0.0146367,-0.0067365,0.0424985,0.0162635,-0.0034343,0.0451161,0.008291,-0.0049858,0.0451161,0.0074617,-0.0097799,0.0424985,0.0146367,-0.0049858,0.0451161,0.0074617,-0.0124475,0.0424985,0.0124475,-0.0097799,0.0424985,0.0146367,-0.0049858,0.0451161,0.0074617,-0.0063457,0.0451161,0.0063457,-0.0124475,0.0424985,0.0124475,-0.0063457,0.0451161,0.0063457,-0.0146367,0.0424985,0.0097799,-0.0124475,0.0424985,0.0124475,-0.0063457,0.0451161,0.0063457,-0.0074617,0.0451161,0.0049858,-0.0146367,0.0424985,0.0097799,-0.0074617,0.0451161,0.0049858,-0.0162635,0.0424985,0.0067365,-0.0146367,0.0424985,0.0097799,-0.0074617,0.0451161,0.0049858,-0.008291,0.0451161,0.0034343,-0.0162635,0.0424985,0.0067365,-0.008291,0.0451161,0.0034343,-0.0172652,0.0Line truncated
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 71 B |
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Cosmic Black Hole",
|
||||
"systemName": "cosmicBlackHole",
|
||||
"author": "21",
|
||||
"version": 1,
|
||||
"meshFile": "cosmic-black-hole.json",
|
||||
"diceAvailable": [
|
||||
"d6"
|
||||
],
|
||||
"material": {
|
||||
"type": "standard",
|
||||
"diffuseTexture": "diffuse.png",
|
||||
"diffuseLevel": 1.65,
|
||||
"specularTexture": "specular.png"
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/react": "~19.2.14",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"babel-plugin-module-resolver": "^5.0.3",
|
||||
"bun-types": "^1.3.11",
|
||||
"esbuild": "^0.28.0",
|
||||
@@ -39,6 +40,7 @@
|
||||
"eslint-config-expo": "^55.0.0",
|
||||
"jest": "~29.7.0",
|
||||
"jest-expo": "~55.0.15",
|
||||
"playwright": "1.62.1",
|
||||
"prettier": "^3.8.1",
|
||||
"react-test-renderer": "^19.2.4",
|
||||
"typescript": "~5.9.3",
|
||||
@@ -528,6 +530,8 @@
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-test-renderer": ["@types/react-test-renderer@19.1.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ=="],
|
||||
|
||||
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
|
||||
|
||||
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
|
||||
@@ -1004,7 +1008,7 @@
|
||||
|
||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
@@ -1484,6 +1488,10 @@
|
||||
|
||||
"pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
|
||||
|
||||
"playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="],
|
||||
|
||||
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
|
||||
|
||||
"pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
|
||||
@@ -2010,6 +2018,8 @@
|
||||
|
||||
"jest-expo/react-test-renderer": ["react-test-renderer@19.2.0", "", { "dependencies": { "react-is": "^19.2.0", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-zLCFMHFE9vy/w3AxO0zNxy6aAupnCuLSVOJYDe/Tp+ayGI1f2PLQsFVPANSD42gdSbmYx5oN+1VWDhcXtq7hAQ=="],
|
||||
|
||||
"jest-haste-map/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"jest-runner/source-map-support": ["source-map-support@0.5.13", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w=="],
|
||||
|
||||
"jest-runtime/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Cosmic Black Hole d6
|
||||
|
||||
Select **Trou noir cosmique** in either player's existing setup theme picker.
|
||||
The id is `cosmicBlackHole`, with preview color `#392064`. Existing profile,
|
||||
setup draft, current-player and fallback handling use the same theme registry.
|
||||
|
||||
## Assets and compatibility
|
||||
|
||||
`assets/dice-themes/cosmicBlackHole/` is the source asset directory committed to
|
||||
the repository. It contains `theme.config.json`, `cosmic-black-hole.json`,
|
||||
`diffuse.png`, `specular.png` and the upstream collider license notice.
|
||||
`scripts/build-cosmic-theme.mjs` deterministically regenerates the mesh and
|
||||
textures using Node built-ins and the locked Dice-Box default collider.
|
||||
|
||||
The implementation was inspected against the installed Dice-Box **1.1.4**,
|
||||
BabylonJS **5.57.1**, and dice-themes **0.2.1** packages. Dice-Box's pared-down
|
||||
Babylon loader imports mesh data, not scene materials or animation groups.
|
||||
Its ThemeLoader accepts `standard`/`color` materials and diffuse, bump and
|
||||
specular texture settings. It has no supported time-uniform/animated shader,
|
||||
emissive material, opacity material, child-effect instancing or animation-start
|
||||
hook. The world also stops rendering when dice sleep. See the corresponding
|
||||
[upstream loader source](https://github.com/3d-dice/dice-box/blob/main/src/components/ThemeLoader.js)
|
||||
and [mesh loader](https://github.com/3d-dice/dice-box/blob/main/src/helpers/babylonFileLoader.js);
|
||||
the installed package was the compatibility authority for this change.
|
||||
|
||||
The theme therefore uses a real single instanceable `d6` mesh, supported
|
||||
`standard` material fields, baked vertex colors, a white diffuse texture and
|
||||
black specular texture. No new material configuration fields, shader injection,
|
||||
CDN assets or runtime dependencies are introduced. Playwright is development-only.
|
||||
|
||||
The mesh has 4,004 triangles per die, shared between Dice-Box instances:
|
||||
|
||||
- One closed pure-black sphere at the local origin, radius 0.046.
|
||||
- A broad violet/blue disk with subtle magenta detail, radius 0.099, tilted
|
||||
through the sphere. It extends beyond the nominal cube's approximately
|
||||
0.0783 half-width, without extending its collider.
|
||||
- 21 bright eight-point star pips, fixed on the six face planes at ±0.081.
|
||||
Standard arrangements use offsets 0 and ±0.043. Selected straight segments
|
||||
form open constellation trails. These are separate from the central disk.
|
||||
- No opaque cubic surface or opaque boundary lines. One-sided face stars
|
||||
reduce confusion from the opposite face, and the core occludes back stars.
|
||||
|
||||
The original collider object (including scale, mass and physical properties),
|
||||
render transform and triangle-to-value map are preserved. Face values are
|
||||
`-Y=1`, `+Z=5`, `+X=4`, `-Z=2`, `+Y=6`, `-X=3`; opposites sum to seven.
|
||||
The visual scale suggests a small space object; “2 cm” is conceptual. There is
|
||||
no screen DPI calibration or change to the physics scale.
|
||||
|
||||
`sync:dice` clears the previous dice bundle, copies Dice-Box and third-party
|
||||
themes, then overlays repository-owned themes. All custom files end up under
|
||||
`android/app/src/main/assets/dice-box/assets/themes/cosmicBlackHole/`.
|
||||
The existing HTML, WebView `file:///android_asset` origin, fetch/XHR and WASM
|
||||
worker polyfills are unchanged.
|
||||
|
||||
## Integration changes
|
||||
|
||||
- `src/constants/themes.ts`: register the label, id and preview swatch.
|
||||
- `src/html/dice-bridge.js`: retain the selected theme across quality/health
|
||||
resets; wait for initialization and `updateConfig({ theme })` before a roll.
|
||||
The real browser check reproduced an undefined worker `postMessage`/
|
||||
`convexHull` race when quality/theme/roll messages arrived together before
|
||||
initialization. The shared initialization promise fixes that race.
|
||||
- `src/components/Dice3DViewer.tsx`: include `postTheme` in the READY callback
|
||||
dependencies so a player change during WebView loading uses the latest theme.
|
||||
- `scripts/sync-dice-box-assets.mjs`: copy custom source themes after third-party assets.
|
||||
- New generator, browser/selection smoke checks, asset/bridge/viewer regression tests.
|
||||
- `package.json` / `bun.lock`: missing React test-renderer declarations,
|
||||
development-only Playwright, and reproducible generator/smoke commands.
|
||||
- `eslint.config.js`: scope the existing WebView scripts' `var` syntax and
|
||||
externally supplied `DiceBoxModule` global correctly.
|
||||
|
||||
`roll('2d6')`, result extraction, fallback random generation, scoring, turn
|
||||
handling and probabilities are unchanged. `THEME` messages and
|
||||
`updateConfig({ theme })` remain the integration interface. `GameScreen.tsx`
|
||||
and the store required no edits.
|
||||
|
||||
## Validation performed
|
||||
|
||||
With Bun 1.3.11 and Node 24.19.0, from the repository root:
|
||||
|
||||
```powershell
|
||||
bun install --frozen-lockfile
|
||||
bun run build:cosmic
|
||||
bun run sync:dice
|
||||
bun run lint
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run test:coverage
|
||||
bun run check:dice:selection
|
||||
$env:DICE_BROWSER_EXECUTABLE = 'C:\Program Files\Google\Chrome\Application\chrome.exe'
|
||||
bun run check:dice:browser
|
||||
```
|
||||
|
||||
For machines without installed Chrome, run `bun x playwright install chromium`
|
||||
and omit `DICE_BROWSER_EXECUTABLE`. `DICE_QA_OUTPUT` optionally selects the
|
||||
screenshot/report directory (default: ignored `android/dice-qa`).
|
||||
|
||||
All five requested project checks pass. There are 50 passing unit/component
|
||||
tests. The repository's coverage checker passes its existing 90% threshold.
|
||||
Lint has no errors and four existing warnings in `useAppStateListener.ts`,
|
||||
`webview-polyfills.js`, `GameScreen.tsx` and `streakService.ts`. Existing React
|
||||
test-renderer deprecation/act warnings also remain. The initial install's
|
||||
postinstall encountered a transient Windows directory lock; subsequent asset
|
||||
sync and the frozen-lock install completed successfully.
|
||||
|
||||
The selection smoke check renders the real SetupScreen and runs its selection
|
||||
handlers and Zustand store with native host primitives/in-memory filesystem
|
||||
mocks. Both player pickers, selected current player, saved draft, rehydration
|
||||
and invalid-id fallback pass. The viewer component test verifies the latest
|
||||
theme on READY, player switching, quality messages and unchanged result delivery.
|
||||
|
||||
The browser check runs the generated offline HTML/polyfills/bundle/bridge in
|
||||
headless Chrome using actual WASM physics and Babylon WebGL2. It maps only
|
||||
Android's virtual asset directory to the identical local file directory via
|
||||
XHR. It verifies real engine results match bridge values (not fallback values),
|
||||
two custom dice at low/medium/high, all six outcomes, every existing selectable
|
||||
theme, custom asset byte equality and no HTTP(S) requests. The final run had
|
||||
14 rolls, 28 valid engine dice, and no missing-file, browser, worker or Babylon
|
||||
errors. Screenshots were inspected during motion and at rest, including all
|
||||
six outcomes; fixed pips remain readable and the core does not cover the entire
|
||||
visible face pattern.
|
||||
|
||||
## Visual and validation limitations
|
||||
|
||||
The accretion disk and star glow are **static baked appearance**, not genuine
|
||||
emission, bloom, animated accretion or lensing. The entire mesh rotates with
|
||||
the die; stars never orbit or drift. Lighting and shadows are still the existing
|
||||
Dice-Box lighting. At edge-on disk angles the disk looks thinner/darker; during
|
||||
motion, stars on other visible faces and the two dice's decorative disks can
|
||||
overlap. The decorative disk can also visually enter the floor or another die
|
||||
because its extent deliberately does not enlarge the physics collider.
|
||||
|
||||
No Android SDK, emulator or attached device was available here. Desktop
|
||||
`file://` rendering and native component/store tests do **not** establish actual
|
||||
Android WebView behavior or mobile frame rate. A device check is still required:
|
||||
build the app, enable airplane mode, select the theme for one player, roll at
|
||||
all three qualities, switch players/themes, restart the app, and inspect
|
||||
WebView/logcat for asset or rendering errors. No emulator or device performance
|
||||
result is claimed.
|
||||
@@ -6,5 +6,11 @@ module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ["dist/*"],
|
||||
},
|
||||
{
|
||||
// These scripts run directly in the Android WebView, outside Metro.
|
||||
files: ["src/html/*.js"],
|
||||
languageOptions: { globals: { DiceBoxModule: "readonly" } },
|
||||
rules: { "no-var": "off" },
|
||||
}
|
||||
]);
|
||||
+6
-1
@@ -12,6 +12,9 @@
|
||||
"test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov && bun run coverage:check",
|
||||
"ci:quality": "bun run lint && bun run typecheck && bun run test:coverage",
|
||||
"sync:dice": "node scripts/sync-dice-box-assets.mjs",
|
||||
"build:cosmic": "node scripts/build-cosmic-theme.mjs",
|
||||
"check:dice:browser": "node scripts/check-dice-browser.mjs",
|
||||
"check:dice:selection": "bun scripts/check-theme-selection.tsx",
|
||||
"postinstall": "bun run sync:dice"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -42,6 +45,7 @@
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/react": "~19.2.14",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"babel-plugin-module-resolver": "^5.0.3",
|
||||
"bun-types": "^1.3.11",
|
||||
"esbuild": "^0.28.0",
|
||||
@@ -49,9 +53,10 @@
|
||||
"eslint-config-expo": "^55.0.0",
|
||||
"jest": "~29.7.0",
|
||||
"jest-expo": "~55.0.15",
|
||||
"playwright": "1.62.1",
|
||||
"prettier": "^3.8.1",
|
||||
"react-test-renderer": "^19.2.4",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/** Deterministic, dependency-free source for the static Dice-Box d6 theme.
|
||||
* Run with `node scripts/build-cosmic-theme.mjs` after installing dependencies.
|
||||
* One render mesh is required: Dice-Box instances d6, not child effects.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = new URL('../', import.meta.url);
|
||||
const output = new URL('assets/dice-themes/cosmicBlackHole/', root);
|
||||
mkdirSync(output, { recursive: true });
|
||||
const source = JSON.parse(readFileSync(new URL(
|
||||
'node_modules/@3d-dice/dice-box/dist/assets/themes/default/default.json', root,
|
||||
), 'utf8'));
|
||||
const base = source.meshes.find((mesh) => mesh.name === 'd6');
|
||||
const collider = source.meshes.find((mesh) => mesh.name === 'd6_collider');
|
||||
const mesh = {
|
||||
name: 'd6', id: 'd6', position: base.position,
|
||||
rotationQuaternion: base.rotationQuaternion, scaling: base.scaling,
|
||||
isVisible: true, isEnabled: true, pickable: false,
|
||||
positions: [], normals: [], uvs: [], colors: [], indices: [],
|
||||
};
|
||||
const add = (a, b) => a.map((v, i) => v + b[i]);
|
||||
const mul = (v, k) => v.map((x) => x * k);
|
||||
const cross = (a, b) => [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];
|
||||
const unit = (v) => mul(v, 1 / Math.hypot(...v));
|
||||
const round = (x) => Math.round(x * 1e7) / 1e7;
|
||||
function triangle(a, b, c, colors, normal) {
|
||||
const start = mesh.positions.length / 3;
|
||||
const n = normal || unit(cross(add(b, mul(a, -1)), add(c, mul(a, -1))));
|
||||
for (const [i, p] of [a, b, c].entries()) {
|
||||
mesh.positions.push(...p.map(round));
|
||||
mesh.normals.push(...n.map(round));
|
||||
mesh.colors.push(...colors[i].map(round), 1);
|
||||
mesh.uvs.push(0.5, 0.5);
|
||||
}
|
||||
// Babylon's left-handed mesh winding is clockwise viewed from outside.
|
||||
mesh.indices.push(start, start + 2, start + 1);
|
||||
}
|
||||
const flat = (a, b, c, color, normal) => triangle(a, b, c, [color, color, color], normal);
|
||||
|
||||
// A single genuinely central black sphere. No hole is painted onto any face.
|
||||
const radius = 0.046;
|
||||
const spherePoint = (lat, lon) => [
|
||||
radius * Math.sin(lat) * Math.cos(lon), radius * Math.cos(lat),
|
||||
radius * Math.sin(lat) * Math.sin(lon),
|
||||
];
|
||||
for (let y = 0; y < 16; y++) {
|
||||
for (let x = 0; x < 32; x++) {
|
||||
const a = spherePoint(y * Math.PI / 16, x * Math.PI / 16);
|
||||
const b = spherePoint((y + 1) * Math.PI / 16, x * Math.PI / 16);
|
||||
const c = spherePoint((y + 1) * Math.PI / 16, (x + 1) * Math.PI / 16);
|
||||
const d = spherePoint(y * Math.PI / 16, (x + 1) * Math.PI / 16);
|
||||
if (y !== 15) flat(a, c, b, [0, 0, 0], unit(add(add(a, b), c)));
|
||||
if (y !== 0) flat(a, d, c, [0, 0, 0], unit(add(add(a, c), d)));
|
||||
}
|
||||
}
|
||||
|
||||
// A broad, continuous, tilted accretion disk, extending beyond the cubic field.
|
||||
// Baked spiral turbulence is vertex color, not moving pips or orbital trails.
|
||||
const diskNormal = unit([0.26, 1, 0.38]);
|
||||
const diskU = unit(cross([0, 0, 1], diskNormal));
|
||||
const diskV = cross(diskNormal, diskU);
|
||||
const diskRadii = [0.0465, 0.049, 0.054, 0.060, 0.068, 0.078, 0.089, 0.099];
|
||||
const palette = [[0.5,0.66,1], [0.65,0.48,1], [0.32,0.24,0.95], [0.28,0.16,0.65],
|
||||
[0.34,0.13,0.57], [0.18,0.19,0.47], [0.10,0.07,0.25], [0.035,0.025,0.085]];
|
||||
function diskPoint(r, a) { return add(mul(diskU, r * Math.cos(a)), mul(diskV, r * Math.sin(a))); }
|
||||
function diskColor(band, angle) {
|
||||
const intensity = 0.78 + 0.22 * Math.sin(angle * 5 - band * 1.9);
|
||||
return mul(palette[band], intensity);
|
||||
}
|
||||
for (let band = 0; band < diskRadii.length - 1; band++) {
|
||||
for (let s = 0; s < 96; s++) {
|
||||
const a = s * Math.PI / 48, b = (s + 1) * Math.PI / 48;
|
||||
const p = diskPoint(diskRadii[band], a), q = diskPoint(diskRadii[band + 1], a);
|
||||
const r = diskPoint(diskRadii[band + 1], b), t = diskPoint(diskRadii[band], b);
|
||||
const cp = diskColor(band, a), cq = diskColor(band + 1, a);
|
||||
const cr = diskColor(band + 1, b), ct = diskColor(band, b);
|
||||
triangle(p, q, r, [cp, cq, cr], diskNormal);
|
||||
triangle(p, r, t, [cp, cr, ct], diskNormal);
|
||||
triangle(p, r, q, [cp, cr, cq], mul(diskNormal, -1));
|
||||
triangle(p, t, r, [cp, ct, cr], mul(diskNormal, -1));
|
||||
}
|
||||
}
|
||||
|
||||
// Standard pip arrangements. Opposite values sum to seven, matching the
|
||||
// unchanged default colliderFaceMap: -Y=1, +Z=5, +X=4, -Z=2, +Y=6, -X=3.
|
||||
const patterns = {
|
||||
1: [[0,0]], 2: [[-1,1],[1,-1]], 3: [[-1,1],[0,0],[1,-1]],
|
||||
4: [[-1,-1],[-1,1],[1,-1],[1,1]],
|
||||
5: [[-1,-1],[-1,1],[0,0],[1,-1],[1,1]],
|
||||
6: [[-1,-1],[-1,0],[-1,1],[1,-1],[1,0],[1,1]],
|
||||
};
|
||||
const faces = [
|
||||
{ value: 1, n: [0,-1,0], u: [1,0,0], v: [0,0,1] },
|
||||
{ value: 5, n: [0,0,1], u: [1,0,0], v: [0,1,0] },
|
||||
{ value: 4, n: [1,0,0], u: [0,0,-1], v: [0,1,0] },
|
||||
{ value: 2, n: [0,0,-1], u: [-1,0,0], v: [0,1,0] },
|
||||
{ value: 6, n: [0,1,0], u: [1,0,0], v: [0,0,-1] },
|
||||
{ value: 3, n: [-1,0,0], u: [0,0,1], v: [0,1,0] },
|
||||
];
|
||||
for (const face of faces) {
|
||||
const point = (x, y) => add(mul(face.n, 0.081), add(mul(face.u, x), mul(face.v, y)));
|
||||
const centers = patterns[face.value].map(([x, y]) => [x * 0.043, y * 0.043]);
|
||||
// Selected straight segments only: intentionally open constellation paths.
|
||||
for (let i = 1; i < centers.length; i++) {
|
||||
if (face.value >= 4 && i % 2 === 0) continue;
|
||||
const [ax, ay] = centers[i - 1], [bx, by] = centers[i];
|
||||
const length = Math.hypot(bx - ax, by - ay);
|
||||
const dx = -(by - ay) / length * 0.00035, dy = (bx - ax) / length * 0.00035;
|
||||
const a = point(ax + dx, ay + dy), b = point(ax - dx, ay - dy);
|
||||
const c = point(bx - dx, by - dy), d = point(bx + dx, by + dy);
|
||||
flat(a, b, c, [0.17,0.22,0.42], face.n);
|
||||
flat(a, c, d, [0.17,0.22,0.42], face.n);
|
||||
}
|
||||
for (const [x, y] of centers) {
|
||||
// Eight-point stars with a pale core and blue/violet baked glow.
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const at = i * Math.PI / 8, next = (i + 1) * Math.PI / 8;
|
||||
const size = (k) => k % 2 ? 0.0046 : (k % 4 ? 0.008 : 0.013);
|
||||
const a = point(x + Math.cos(at) * size(i), y + Math.sin(at) * size(i));
|
||||
const b = point(x + Math.cos(next) * size(i + 1), y + Math.sin(next) * size(i + 1));
|
||||
triangle(point(x, y), a, b, [[1,0.98,0.86], [0.50,0.66,1], [0.50,0.66,1]], face.n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const model = {
|
||||
producer: { name: '21 cosmic theme generator', version: '1', file: 'cosmic-black-hole.json' },
|
||||
meshes: [mesh, collider], colliderFaceMap: { d6: source.colliderFaceMap.d6 },
|
||||
};
|
||||
writeFileSync(new URL('cosmic-black-hole.json', output), JSON.stringify(model) + '\n');
|
||||
writeFileSync(new URL('theme.config.json', output), JSON.stringify({
|
||||
name: 'Cosmic Black Hole', systemName: 'cosmicBlackHole', author: '21', version: 1,
|
||||
meshFile: 'cosmic-black-hole.json', diceAvailable: ['d6'],
|
||||
material: { type: 'standard', diffuseTexture: 'diffuse.png', diffuseLevel: 1.65,
|
||||
specularTexture: 'specular.png' },
|
||||
}, null, 2) + '\n');
|
||||
|
||||
// Tiny constant textures let supported standard material use the baked vertex
|
||||
// colors. Black specular prevents lighting from whitening the event horizon.
|
||||
function png(rgb) {
|
||||
const crc = (bytes) => {
|
||||
let c = 0xffffffff;
|
||||
for (const byte of bytes) { c ^= byte; for (let i = 0; i < 8; i++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); }
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
};
|
||||
const chunk = (type, data) => {
|
||||
const body = Buffer.concat([Buffer.from(type), data]);
|
||||
const size = Buffer.alloc(4); size.writeUInt32BE(data.length);
|
||||
const checksum = Buffer.alloc(4); checksum.writeUInt32BE(crc(body));
|
||||
return Buffer.concat([size, body, checksum]);
|
||||
};
|
||||
const header = Buffer.alloc(13); header.writeUInt32BE(2, 0); header.writeUInt32BE(2, 4); header[8] = 8; header[9] = 2;
|
||||
return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]), chunk('IHDR', header),
|
||||
chunk('IDAT', deflateSync(Buffer.from([0,...rgb,...rgb,0,...rgb,...rgb]))), chunk('IEND', Buffer.alloc(0))]);
|
||||
}
|
||||
writeFileSync(new URL('diffuse.png', output), png([255,255,255]));
|
||||
writeFileSync(new URL('specular.png', output), png([0,0,0]));
|
||||
console.log(`Built ${mesh.indices.length / 3} triangles in ${fileURLToPath(output)}`);
|
||||
@@ -0,0 +1,108 @@
|
||||
/** Real Dice-Box / WASM / WebGL check, with no HTTP server or runtime network.
|
||||
* `bun run sync:dice && bun run check:dice:browser`
|
||||
* Install Chromium with `bun x playwright install chromium`, or set
|
||||
* DICE_BROWSER_EXECUTABLE to an installed Chromium-based browser executable.
|
||||
* This exercises desktop file:// support, not an Android WebView emulator.
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const assets = resolve(root, 'android/app/src/main/assets');
|
||||
const output = resolve(process.env.DICE_QA_OUTPUT || resolve(root, 'android/dice-qa'));
|
||||
mkdirSync(output, { recursive: true });
|
||||
const harnessFile = resolve(assets, `.dice-qa-${process.pid}.html`);
|
||||
const errors = [], requests = [], results = [];
|
||||
const browser = await chromium.launch({
|
||||
executablePath: process.env.DICE_BROWSER_EXECUTABLE || undefined,
|
||||
headless: true,
|
||||
args: ['--allow-file-access-from-files', '--enable-unsafe-swiftshader'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 420, height: 480 }, deviceScaleFactor: 2 });
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
page.on('console', message => {
|
||||
if (['error', 'warning'].includes(message.type())) errors.push(message.text());
|
||||
});
|
||||
page.on('requestfailed', request => errors.push(`${request.url()}: ${request.failure()?.errorText}`));
|
||||
page.on('request', request => requests.push(request.url()));
|
||||
await page.addInitScript(({ assetRoot }) => {
|
||||
// Only map Android's virtual asset directory to the identical generated
|
||||
// directory on desktop. Production fetch/Worker/WASM polyfills still run.
|
||||
const open = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...args) {
|
||||
return open.call(this, method, String(url).replace('file:///android_asset', assetRoot), ...args);
|
||||
};
|
||||
window.messages = [];
|
||||
window.ReactNativeWebView = { postMessage: message => window.messages.push(JSON.parse(message)) };
|
||||
}, { assetRoot: pathToFileURL(assets).href });
|
||||
const html = readFileSync(resolve(assets, 'dice3d.html'), 'utf8').replace(
|
||||
'<script src="./dice-bridge.js"></script>',
|
||||
`<script>
|
||||
const Original = DiceBoxModule.default;
|
||||
window.boxes = [];
|
||||
DiceBoxModule = {default: class extends Original {
|
||||
constructor(options) { super(options); window.boxes.push(this); }
|
||||
}};
|
||||
document.body.style.background = '#080b18';
|
||||
</script><script src="./dice-bridge.js"></script>`,
|
||||
);
|
||||
writeFileSync(harnessFile, html);
|
||||
await page.goto(pathToFileURL(harnessFile).href);
|
||||
await page.waitForFunction(() => window.messages.some(message => message.type === 'READY'));
|
||||
const send = (type, payload) => page.evaluate(({ type, payload }) => {
|
||||
window.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({ type, payload }) }));
|
||||
}, { type, payload });
|
||||
async function roll(theme, label, quality) {
|
||||
if (quality) await send('QUALITY', { level: quality });
|
||||
await send('THEME', { theme });
|
||||
await send('ROLL');
|
||||
if (label === 'cosmic-high') {
|
||||
await page.waitForTimeout(250);
|
||||
await page.screenshot({ path: resolve(output, 'cosmic-rolling.png') });
|
||||
}
|
||||
await page.waitForFunction(count => window.messages.filter(message => message.type === 'ROLL_RESULT').length > count,
|
||||
results.length, { timeout: 40000 });
|
||||
const result = await page.evaluate(() => ({
|
||||
bridge: window.messages.filter(message => message.type === 'ROLL_RESULT').at(-1).payload.dice,
|
||||
config: window.boxes.at(-1).config,
|
||||
engine: window.boxes.at(-1).getRollResults(),
|
||||
}));
|
||||
results.push({ label, ...result });
|
||||
const dice = result.engine.flatMap(group => group.rolls);
|
||||
assert.equal(dice.length, 2, `${label}: expected two engine dice, not bridge fallback`);
|
||||
assert.deepEqual(result.bridge, dice.map(die => die.value));
|
||||
assert.ok(dice.every(die => Number.isInteger(die.value) && die.value >= 1 && die.value <= 6));
|
||||
if (theme === 'cosmicBlackHole') assert.ok(dice.every(die => die.theme === theme));
|
||||
if (quality) assert.equal(result.config.scale, { low: 7.5, medium: 8.8, high: 10 }[quality]);
|
||||
await page.screenshot({ path: resolve(output, `${label}.png`) });
|
||||
console.log(`${label}: ${result.bridge.join(', ')}`);
|
||||
return result.bridge;
|
||||
}
|
||||
const facesSeen = new Set();
|
||||
for (const quality of ['low', 'medium', 'high']) {
|
||||
for (const value of await roll('cosmicBlackHole', `cosmic-${quality}`, quality)) facesSeen.add(value);
|
||||
}
|
||||
for (const theme of ['default', 'rust', 'blueGreenMetal', 'gemstone', 'gemstoneMarble', 'rock', 'wooden', 'smooth', 'smooth-pip', 'diceOfRolling']) {
|
||||
await roll(theme, `existing-${theme}`);
|
||||
}
|
||||
// Exercise all six outcomes through real physics, without supplying values.
|
||||
for (let i = 0; i < 24 && facesSeen.size < 6; i++) {
|
||||
for (const value of await roll('cosmicBlackHole', `cosmic-faces-${i}`)) facesSeen.add(value);
|
||||
}
|
||||
assert.equal(facesSeen.size, 6, 'Did not observe all six values; rerun the random smoke check');
|
||||
assert.deepEqual(errors, [], 'WebGL, worker, texture or file errors');
|
||||
assert.ok(requests.every(url => /^(file:|blob:|data:)/.test(url)), 'Unexpected runtime network request');
|
||||
for (const file of readdirSync(resolve(root, 'assets/dice-themes/cosmicBlackHole'))) {
|
||||
assert.deepEqual(readFileSync(resolve(root, 'assets/dice-themes/cosmicBlackHole', file)),
|
||||
readFileSync(resolve(assets, 'dice-box/assets/themes/cosmicBlackHole', file)));
|
||||
}
|
||||
console.log('PASS: both engine dice, all six outcomes, three qualities, all existing themes, local files only.');
|
||||
} finally {
|
||||
writeFileSync(resolve(output, 'browser-validation.json'), JSON.stringify({ results, errors, requests }, null, 2));
|
||||
rmSync(harnessFile, { force: true });
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/** Native component/store smoke check. Only native host views and filesystem
|
||||
* are mocked; the real SetupScreen, selection handlers and Zustand store run.
|
||||
* Run separately from unit coverage: `bun run check:dice:selection`.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { mock } from 'bun:test';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const primitive = (name: string) => ({ children, ...props }: any) => React.createElement(name, props, children);
|
||||
const list = ({ data, renderItem }: any) => React.createElement('List', {}, data.map((item: any, index: number) =>
|
||||
React.createElement(React.Fragment, { key: index }, renderItem({ item, index, getIndex: () => index, drag: () => {}, isActive: false }))));
|
||||
mock.module('react-native', () => ({
|
||||
View: primitive('View'), Text: primitive('Text'), Pressable: primitive('Pressable'),
|
||||
ScrollView: primitive('ScrollView'), TextInput: primitive('TextInput'), Switch: primitive('Switch'), FlatList: list,
|
||||
Alert: { alert: (...args: any[]) => { throw new Error(`Unexpected alert: ${args}`); } },
|
||||
StyleSheet: { create: (styles: any) => styles, absoluteFillObject: {} },
|
||||
Platform: { OS: 'android', select: (values: any) => values.android ?? values.default },
|
||||
}));
|
||||
mock.module('@expo/vector-icons', () => ({ FontAwesome: primitive('Icon') }));
|
||||
mock.module('react-native-safe-area-context', () => ({ SafeAreaView: primitive('SafeAreaView') }));
|
||||
mock.module('react-native-draggable-flatlist', () => ({ default: list }));
|
||||
mock.module('../src/components/QualitySlider', () => ({ QualitySlider: () => null }));
|
||||
mock.module('../src/components/RulesModal', () => ({ RulesModal: () => null }));
|
||||
const disk = new Map<string, string>();
|
||||
mock.module('expo-file-system/legacy', () => ({
|
||||
documentDirectory: 'memory://',
|
||||
readAsStringAsync: async (file: string) => { if (!disk.has(file)) throw new Error('Missing'); return disk.get(file); },
|
||||
writeAsStringAsync: async (file: string, value: string) => { disk.set(file, value); },
|
||||
deleteAsync: async (file: string) => { disk.delete(file); },
|
||||
}));
|
||||
const { SetupScreen } = await import('../src/screens/SetupScreen');
|
||||
const { useGameStore } = await import('../src/store');
|
||||
await useGameStore.persist.rehydrate();
|
||||
let tree: ReactTestRenderer | undefined;
|
||||
const routes: string[] = [];
|
||||
const navigation = { canGoBack: () => false, navigate: (route: string) => routes.push(route) };
|
||||
try {
|
||||
await act(async () => { tree = create(<SetupScreen navigation={navigation as any} route={{} as any} />); });
|
||||
const buttons = (label: string) => tree!.root.findAll(node => String(node.type) === 'Pressable' &&
|
||||
node.findAll(child => String(child.type) === 'Text' && child.children.join('') === label).length > 0);
|
||||
assert.equal(buttons('Trou noir cosmique').length, 2, 'Both player pickers must contain the theme');
|
||||
await act(async () => { buttons('Trou noir cosmique')[0].props.onPress(); });
|
||||
assert.equal(useGameStore.getState().persistentPlayerThemes[0], 'cosmicBlackHole');
|
||||
assert.deepEqual(useGameStore.getState().setupDraft.playerThemes, ['cosmicBlackHole', 'default']);
|
||||
await act(async () => { buttons('Démarrer la partie')[0].props.onPress(); });
|
||||
assert.deepEqual(useGameStore.getState().players.map(player => player.theme), ['cosmicBlackHole', 'default']);
|
||||
assert.equal(useGameStore.getState().players[useGameStore.getState().currentPlayerIndex].theme, 'cosmicBlackHole');
|
||||
assert.ok(routes.includes('Game'));
|
||||
await act(async () => { tree!.unmount(); tree = undefined; });
|
||||
await useGameStore.persist.rehydrate();
|
||||
assert.equal(useGameStore.getState().setupDraft.playerThemes[0], 'cosmicBlackHole');
|
||||
assert.equal(useGameStore.getState().players[0].theme, 'cosmicBlackHole');
|
||||
useGameStore.getState().setPersistedPlayerTheme(1, 'unknown-theme');
|
||||
assert.equal(useGameStore.getState().persistentPlayerThemes[1], 'default');
|
||||
console.log('PASS: both setup pickers, selection, current player, saved draft, rehydration and invalid-theme fallback.');
|
||||
} finally {
|
||||
await act(async () => { tree?.unmount(); });
|
||||
}
|
||||
@@ -51,6 +51,13 @@ cpSync(
|
||||
);
|
||||
|
||||
// 3. Copy the offline HTML template and JS files into android assets
|
||||
// Repository-owned themes override bundled themes, never the other way around.
|
||||
cpSync(
|
||||
resolve(ROOT, 'assets/dice-themes'),
|
||||
resolve(ANDROID_ASSETS, 'dice-box/assets/themes'),
|
||||
{ recursive: true },
|
||||
);
|
||||
|
||||
for (const file of ['dice3d.html', 'webview-polyfills.js', 'dice-bridge.js']) {
|
||||
copyFileSync(
|
||||
resolve(ROOT, 'src/html', file),
|
||||
|
||||
@@ -182,7 +182,7 @@ export function Dice3DViewer({
|
||||
return;
|
||||
}
|
||||
},
|
||||
[postQualitySetting, postResize],
|
||||
[postQualitySetting, postResize, postTheme],
|
||||
);
|
||||
|
||||
const onWebError = useCallback(
|
||||
@@ -231,4 +231,4 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ export type DiceTheme = {
|
||||
*/
|
||||
export const DICE_THEMES: DiceTheme[] = [
|
||||
{ id: 'default', label: 'Classique', previewColor: '#5a7fbb' },
|
||||
{ id: 'cosmicBlackHole', label: 'Trou noir cosmique', previewColor: '#392064' },
|
||||
{ id: 'rust', label: 'Rouille', previewColor: '#aa4f4a' },
|
||||
{ id: 'blueGreenMetal', label: 'Métal', previewColor: '#3d7a72' },
|
||||
{ id: 'gemstone', label: 'Gemme', previewColor: '#7d44a8' },
|
||||
|
||||
+14
-6
@@ -68,17 +68,20 @@
|
||||
(typeof DiceBoxModule !== 'undefined' && DiceBoxModule.default) || null;
|
||||
|
||||
var diceBox = null;
|
||||
var diceBoxReady = null;
|
||||
var isRolling = false;
|
||||
var resizeQueued = false;
|
||||
var healthCheckFailures = 0;
|
||||
var qualityLevel = 'high';
|
||||
var qualityResetQueued = false;
|
||||
var selectedTheme = 'default';
|
||||
var themeReady = Promise.resolve();
|
||||
|
||||
function ensureDiceBox() {
|
||||
if (diceBox) return Promise.resolve(diceBox);
|
||||
if (diceBoxReady) return diceBoxReady;
|
||||
if (!DiceBox) return Promise.reject(new Error('DiceBox module not loaded'));
|
||||
|
||||
return (window.__diceReady || Promise.resolve()).then(function () {
|
||||
diceBoxReady = (window.__diceReady || Promise.resolve()).then(function () {
|
||||
if (diceBox) return diceBox;
|
||||
|
||||
var cfg = QUALITY_PRESETS[qualityLevel] || QUALITY_PRESETS.high;
|
||||
@@ -88,7 +91,7 @@
|
||||
id: 'dice-box-canvas',
|
||||
origin: 'file:///android_asset',
|
||||
assetPath: '/dice-box/assets/',
|
||||
theme: 'default',
|
||||
theme: selectedTheme,
|
||||
offscreen: false,
|
||||
scale: cfg.scale,
|
||||
gravity: cfg.gravity,
|
||||
@@ -101,12 +104,14 @@
|
||||
return diceBox;
|
||||
});
|
||||
});
|
||||
return diceBoxReady;
|
||||
}
|
||||
|
||||
function resetDiceBox() {
|
||||
var container = document.getElementById('dice-box');
|
||||
if (container) container.innerHTML = '';
|
||||
diceBox = null;
|
||||
diceBoxReady = null;
|
||||
|
||||
return ensureDiceBox()
|
||||
.then(function () { queueResize(); })
|
||||
@@ -178,7 +183,9 @@
|
||||
isRolling = true;
|
||||
|
||||
ensureDiceBox()
|
||||
.then(function (box) { return box.roll('2d6'); })
|
||||
.then(function (box) {
|
||||
return themeReady.then(function () { return box.roll('2d6'); });
|
||||
})
|
||||
.then(function (result) {
|
||||
emit({
|
||||
type: 'ROLL_RESULT',
|
||||
@@ -201,9 +208,10 @@
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (!theme || typeof theme !== 'string') return;
|
||||
ensureDiceBox()
|
||||
selectedTheme = theme;
|
||||
themeReady = themeReady.then(function () { return ensureDiceBox(); })
|
||||
.then(function (box) {
|
||||
box.updateConfig({ theme: theme });
|
||||
return box.updateConfig({ theme: theme });
|
||||
})
|
||||
.catch(function () { /* noop */ });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { forwardRef, useImperativeHandle } from 'react';
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
const sent: any[] = [];
|
||||
mock.module('react-native-webview', () => ({
|
||||
WebView: forwardRef((props: any, ref) => {
|
||||
useImperativeHandle(ref, () => ({ postMessage: (s: string) => sent.push(JSON.parse(s)) }));
|
||||
return React.createElement('WebView', props);
|
||||
}),
|
||||
}));
|
||||
const { Dice3DViewer } = await import('../../src/components/Dice3DViewer');
|
||||
|
||||
describe('Dice3DViewer player theme', () => {
|
||||
it('sends the latest player theme after READY and preserves Android file loading', async () => {
|
||||
const previousRaf = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(0); return 0; };
|
||||
let tree: ReactTestRenderer | undefined;
|
||||
const onResult = mock(() => {});
|
||||
try {
|
||||
await act(async () => { tree = create(<Dice3DViewer rollSignal={0} qualitySetting="high" theme="default" onRollResult={onResult} />); });
|
||||
await act(async () => { tree!.update(<Dice3DViewer rollSignal={0} qualitySetting="high" theme="cosmicBlackHole" onRollResult={onResult} />); });
|
||||
const webview = () => tree!.root.find(node => String(node.type) === 'WebView');
|
||||
expect(webview().props.source).toEqual({ uri: 'file:///android_asset/dice3d.html' });
|
||||
sent.length = 0;
|
||||
await act(async () => { webview().props.onMessage({ nativeEvent: { data: JSON.stringify({ type: 'READY' }) } }); });
|
||||
expect(sent.filter(m => m.type === 'THEME').every(m => m.payload.theme === 'cosmicBlackHole')).toBe(true);
|
||||
expect(sent).toContainEqual({ type: 'THEME', payload: { theme: 'cosmicBlackHole' } });
|
||||
await act(async () => { tree!.update(<Dice3DViewer rollSignal={1} qualitySetting="low" theme="rust" onRollResult={onResult} />); });
|
||||
expect(sent).toContainEqual({ type: 'THEME', payload: { theme: 'rust' } });
|
||||
expect(sent).toContainEqual({ type: 'ROLL' });
|
||||
expect(sent).toContainEqual({ type: 'QUALITY', payload: { level: 'low' } });
|
||||
await act(async () => { webview().props.onMessage({ nativeEvent: { data: JSON.stringify({ type: 'ROLL_RESULT', payload: { dice: [2,5] } }) } }); });
|
||||
expect(onResult).toHaveBeenCalledWith([2,5]);
|
||||
} finally {
|
||||
await act(async () => { tree?.unmount(); });
|
||||
globalThis.requestAnimationFrame = previousRaf;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { DICE_THEMES, DEFAULT_DICE_THEME, isDiceTheme } from '../../src/constants/themes';
|
||||
|
||||
const root = resolve(import.meta.dir, '../..');
|
||||
const sourcePath = resolve(root, 'assets/dice-themes/cosmicBlackHole');
|
||||
const json = (path: string) => JSON.parse(readFileSync(path, 'utf8'));
|
||||
const custom = json(resolve(sourcePath, 'cosmic-black-hole.json'));
|
||||
const original = json(resolve(root, 'node_modules/@3d-dice/dice-box/dist/assets/themes/default/default.json'));
|
||||
|
||||
describe('cosmicBlackHole Dice-Box asset contract', () => {
|
||||
it('registers a selectable theme without removing existing themes or changing fallback', () => {
|
||||
expect(isDiceTheme('cosmicBlackHole')).toBe(true);
|
||||
expect(isDiceTheme(null)).toBe(false);
|
||||
expect(isDiceTheme('missing-theme')).toBe(false);
|
||||
expect(DEFAULT_DICE_THEME).toBe('default');
|
||||
expect(DICE_THEMES.map(t => t.id)).toEqual(['default', 'cosmicBlackHole', 'rust', 'blueGreenMetal',
|
||||
'gemstone', 'gemstoneMarble', 'rock', 'wooden', 'smooth', 'smooth-pip', 'diceOfRolling']);
|
||||
});
|
||||
|
||||
it('preserves the exact d6 collider, mass, scale and triangle-to-result map', () => {
|
||||
expect(custom.meshes.find((m: { name: string }) => m.name === 'd6_collider'))
|
||||
.toEqual(original.meshes.find((m: { name: string }) => m.name === 'd6_collider'));
|
||||
expect(custom.colliderFaceMap.d6).toEqual(original.colliderFaceMap.d6);
|
||||
expect([...new Set(Object.values(custom.colliderFaceMap.d6))].sort()).toEqual([1,2,3,4,5,6]);
|
||||
});
|
||||
|
||||
it('has one instanceable render mesh with valid geometry, central core and fixed face stars', () => {
|
||||
expect(custom.meshes.map((m: { name: string }) => m.name)).toEqual(['d6', 'd6_collider']);
|
||||
const mesh = custom.meshes[0];
|
||||
const count = mesh.positions.length / 3;
|
||||
expect(mesh.normals.length).toBe(count * 3);
|
||||
expect(mesh.colors.length).toBe(count * 4);
|
||||
expect(mesh.uvs.length).toBe(count * 2);
|
||||
expect(mesh.indices.every((i: number) => Number.isInteger(i) && i >= 0 && i < count)).toBe(true);
|
||||
expect(mesh.positions.every(Number.isFinite)).toBe(true);
|
||||
expect(mesh.indices.length / 3).toBeLessThan(5000);
|
||||
expect(mesh.animations).toBeUndefined();
|
||||
// The black sphere must be closed, including both poles. Otherwise the
|
||||
// far side of the disk can show through the event horizon while rolling.
|
||||
const coreEdges = new Map<string, number>();
|
||||
for (let i = 0; i < mesh.indices.length; i += 3) {
|
||||
const ids: number[] = mesh.indices.slice(i, i + 3);
|
||||
if (!ids.every(id => mesh.colors.slice(id * 4, id * 4 + 3).every((v: number) => v === 0))) continue;
|
||||
const points = ids.map(id => mesh.positions.slice(id * 3, id * 3 + 3).join(','));
|
||||
expect(new Set(points).size).toBe(3);
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const key = [points[j], points[(j + 1) % 3]].sort().join('|');
|
||||
coreEdges.set(key, (coreEdges.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
expect(coreEdges.size).toBeGreaterThan(0);
|
||||
expect([...coreEdges.values()].every(count => count === 2)).toBe(true);
|
||||
// Each bright star center has 16 fan triangles and is on its fixed face.
|
||||
const centers = new Map<string, number[]>();
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (mesh.colors[i * 4] === 1 && mesh.colors[i * 4 + 1] === 0.98) {
|
||||
const p = mesh.positions.slice(i * 3, i * 3 + 3);
|
||||
centers.set(p.join(','), p);
|
||||
}
|
||||
}
|
||||
expect(centers.size).toBe(21);
|
||||
for (const [axis, sign, value] of [[1,-1,1],[2,1,5],[0,1,4],[2,-1,2],[1,1,6],[0,-1,3]]) {
|
||||
const faceStars = [...centers.values()].filter(p => p[axis] === sign * 0.081);
|
||||
expect(faceStars.length).toBe(value);
|
||||
for (const p of faceStars) {
|
||||
expect(p.filter((_: number, i: number) => i !== axis).every((v: number) => [0,0.043,-0.043].includes(v))).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('packages every custom file byte-for-byte after sync, with only supported material keys', () => {
|
||||
const config = json(resolve(sourcePath, 'theme.config.json'));
|
||||
expect(config.diceAvailable).toEqual(['d6']);
|
||||
expect(Object.keys(config.material).sort()).toEqual(['diffuseLevel', 'diffuseTexture', 'specularTexture', 'type']);
|
||||
expect(config.material.type).toBe('standard');
|
||||
for (const name of [config.meshFile, config.material.diffuseTexture, config.material.specularTexture, 'theme.config.json']) {
|
||||
expect(readdirSync(sourcePath)).toContain(name);
|
||||
expect(readFileSync(resolve(root, 'android/app/src/main/assets/dice-box/assets/themes/cosmicBlackHole', name)))
|
||||
.toEqual(readFileSync(resolve(sourcePath, name)));
|
||||
}
|
||||
for (const theme of DICE_THEMES) {
|
||||
expect(json(resolve(root, 'android/app/src/main/assets/dice-box/assets/themes', theme.id, 'theme.config.json')).systemName).toBe(theme.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
const source = readFileSync(new URL('../../src/html/dice-bridge.js', import.meta.url), 'utf8');
|
||||
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
||||
function harness() {
|
||||
const messages: any[] = [];
|
||||
const boxes: FakeBox[] = [];
|
||||
let handler: (event: { data: string }) => void;
|
||||
let releaseTheme: (() => void) | undefined;
|
||||
let releaseInit: (() => void) | undefined;
|
||||
let delayTheme = false, delayInit = false;
|
||||
class FakeBox {
|
||||
config: any;
|
||||
rolls: string[] = [];
|
||||
constructor(config: any) { this.config = config; boxes.push(this); }
|
||||
init() { return delayInit ? new Promise<void>(r => { releaseInit = r; }) : Promise.resolve(); }
|
||||
updateConfig(config: any) {
|
||||
return (delayTheme ? new Promise<void>(r => { releaseTheme = r; }) : Promise.resolve())
|
||||
.then(() => { this.config = { ...this.config, ...config }; });
|
||||
}
|
||||
roll(notation: string) { this.rolls.push(notation); return Promise.resolve([{ value: 1 }, { value: 6 }]); }
|
||||
resizeWorld() {}
|
||||
}
|
||||
runInNewContext(source, {
|
||||
DiceBoxModule: { default: FakeBox }, console, setInterval: () => 0,
|
||||
requestAnimationFrame: (f: () => void) => f(),
|
||||
window: { __diceReady: Promise.resolve(), addEventListener: (name: string, f: typeof handler) => { if (name === 'message') handler = f; },
|
||||
ReactNativeWebView: { postMessage: (s: string) => messages.push(JSON.parse(s)) } },
|
||||
document: { addEventListener: () => {}, getElementById: () => ({ innerHTML: '' }) },
|
||||
});
|
||||
return { boxes, messages, send: (type: string, payload?: any) => handler({ data: JSON.stringify({ type, payload }) }),
|
||||
delayTheme: () => { delayTheme = true; }, finishTheme: () => releaseTheme?.(),
|
||||
delayInit: () => { delayInit = true; }, finishInit: () => releaseInit?.() };
|
||||
}
|
||||
|
||||
describe('dice theme bridge lifecycle', () => {
|
||||
it('waits for theme loading before rolling both dice with unchanged results', async () => {
|
||||
const h = harness(); await flush(); h.delayTheme();
|
||||
h.send('THEME', { theme: 'cosmicBlackHole' }); h.send('ROLL'); await flush();
|
||||
expect(h.boxes[0].rolls).toEqual([]);
|
||||
h.finishTheme(); await flush();
|
||||
expect(h.boxes[0].config.theme).toBe('cosmicBlackHole');
|
||||
expect(h.boxes[0].rolls).toEqual(['2d6']);
|
||||
expect(h.messages.at(-1)).toEqual({ type: 'ROLL_RESULT', payload: { dice: [1,6] } });
|
||||
});
|
||||
|
||||
for (const level of ['low', 'medium', 'high']) {
|
||||
it(`retains the selected theme across ${level} quality and reset, waiting for initialization`, async () => {
|
||||
const h = harness(); await flush();
|
||||
h.send('THEME', { theme: 'cosmicBlackHole' }); await flush();
|
||||
h.send('QUALITY', { level }); await flush();
|
||||
h.delayInit(); h.send('RESET'); h.send('ROLL'); await flush();
|
||||
expect(h.boxes.at(-1)?.rolls).toEqual([]);
|
||||
h.finishInit(); await flush();
|
||||
expect(h.boxes.at(-1)?.config.theme).toBe('cosmicBlackHole');
|
||||
expect(h.boxes.at(-1)?.rolls).toEqual(['2d6']);
|
||||
});
|
||||
}
|
||||
|
||||
it('switches players and back to the default theme through updateConfig', async () => {
|
||||
const h = harness(); await flush();
|
||||
for (const theme of ['cosmicBlackHole', 'rust', 'default']) {
|
||||
h.send('THEME', { theme }); h.send('ROLL'); await flush();
|
||||
expect(h.boxes[0].config.theme).toBe(theme);
|
||||
}
|
||||
expect(h.boxes[0].rolls).toEqual(['2d6', '2d6', '2d6']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user