feat(control): 3D-кубики в отдельном окне

Бросок синхронизирован на пульте и презентации; d100 как десятки; оверлей скрывается только кнопкой «Убрать».

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-04 13:01:34 +08:00
parent 86d6d157f0
commit 9ac01d217f
137 changed files with 958 additions and 1 deletions
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DiceRollSessionStore } from './diceRollSessionStore';
import {
D100_FACE_VALUES,
formatPredeterminedDiceNotation,
snapD100Value,
} from '../../shared/types/diceRoll';
void test('diceRoll: roll produces values in range and notation', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 3, sides: 6 });
const st = store.getState();
assert.equal(st.active, true);
assert.equal(st.notation, '3d6');
assert.equal(st.values.length, 3);
assert.ok(st.values.every((v) => v >= 1 && v <= 6));
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
});
void test('diceRoll: d100 values are tens faces only', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 5, sides: 100 });
const st = store.getState();
assert.equal(st.notation, '5d100');
assert.equal(st.values.length, 5);
const allowed = new Set<number>(D100_FACE_VALUES);
assert.ok(st.values.every((v) => allowed.has(v)));
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
});
void test('diceRoll: clear resets', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
store.dispatch({ kind: 'clear' });
assert.equal(store.getState().active, false);
assert.deepEqual(store.getState().values, []);
});
void test('diceRoll: hideOverlay keeps values for master UI', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 2, sides: 6 });
const values = [...store.getState().values];
const total = store.getState().total;
store.dispatch({ kind: 'hideOverlay' });
assert.equal(store.getState().active, false);
assert.deepEqual(store.getState().values, values);
assert.equal(store.getState().total, total);
assert.equal(store.getState().notation, '2d6');
});
void test('formatPredeterminedDiceNotation', () => {
assert.equal(formatPredeterminedDiceNotation(2, 20, [15, 7]), '2d20@15,7');
assert.equal(formatPredeterminedDiceNotation(2, 100, [20, 100]), '2d100@20,100');
assert.equal(formatPredeterminedDiceNotation(1, 100, [27]), '1d100@30');
});
void test('snapD100Value', () => {
assert.equal(snapD100Value(20), 20);
assert.equal(snapD100Value(21), 30);
assert.equal(snapD100Value(100), 100);
assert.equal(snapD100Value(1), 10);
});
+64
View File
@@ -0,0 +1,64 @@
import {
clampDiceCount,
createInitialDiceRollState,
formatDiceNotation,
isDiceSides,
rollDiceValues,
type DiceRollEvent,
type DiceRollState,
} from '../../shared/types/diceRoll';
export class DiceRollSessionStore {
private state: DiceRollState = createInitialDiceRollState();
getState(): DiceRollState {
return this.state;
}
clear(): DiceRollState {
this.state = createInitialDiceRollState();
return this.state;
}
dispatch(event: DiceRollEvent): DiceRollState {
switch (event.kind) {
case 'clear': {
if (!this.state.active && this.state.values.length === 0) return this.state;
this.state = {
...createInitialDiceRollState(),
revision: this.state.revision + 1,
};
return this.state;
}
case 'hideOverlay': {
if (!this.state.active) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
active: false,
startedAt: null,
};
return this.state;
}
case 'roll': {
if (!isDiceSides(event.sides)) return this.state;
const count = clampDiceCount(event.count);
const values = rollDiceValues(count, event.sides);
const total = values.reduce((a, b) => a + b, 0);
this.state = {
revision: this.state.revision + 1,
active: true,
notation: formatDiceNotation(count, event.sides),
values,
total,
startedAt: Date.now(),
};
return this.state;
}
default: {
const _exhaustive: never = event;
return _exhaustive;
}
}
}
}