Add offline cosmic black hole d6 theme

This commit is contained in:
Codex committed 2026-09-07 13:16:30 +02:00
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"
}
}
+11 -1
View File
@@ -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=="],
+139
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+160
View File
@@ -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)}`);
+108
View File
@@ -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();
}
+60
View File
@@ -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(); });
}
+7
View File
@@ -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),
+2 -2
View 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',
},
});
});
+1
View File
@@ -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
View File
@@ -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 */ });
}
+40
View File
@@ -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;
}
});
});
+87
View File
@@ -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);
}
});
});
+71
View File
@@ -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']);
});
});