M0: план разработки, техническая структура, каркас проекта

- docs/PLAN.md — план по этапам, обновляется по мере работы
- docs/TECHNICAL.md — слои, карта папок, контракты, рабочие процессы
- docs/DECISIONS.md — журнал решений, принятых самостоятельно
- разрешение 1080x1920, fit-width (портрет, 4:3..21:9)
- каркас assets/scripts (core / modules / app / ui)
- headless-тесты на vitest + strict typecheck + защита чистоты core
- Rng: детерминированный генератор и перемешивание

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ftc
2026-09-13 10:28:49 +03:00
co-authored by Claude Opus 5
parent 47e59eab38
commit c980440856
31 changed files with 2333 additions and 1 deletions
+65
View File
@@ -0,0 +1,65 @@
/**
* Deterministic pseudo-random generator.
*
* The whole rules core is reproducible from a seed: given the same seed and the
* same sequence of actions, a match replays identically. That is what lets a bug
* report be a single integer, and what keeps the soak tests meaningful.
*
* Algorithm is mulberry32 — small, fast, and good enough for shuffling cards.
* Its entire state is one 32-bit integer, so it serialises into the save/state
* without ceremony.
*/
export class Rng {
private _state: number;
constructor(seed: number) {
// Force to uint32 so a negative or fractional seed still behaves.
this._state = seed >>> 0;
}
/** Current internal state, for serialising a game in progress. */
get state(): number {
return this._state;
}
/** Restores a generator to a previously captured state. */
static fromState(state: number): Rng {
return new Rng(state);
}
/** Uniform float in [0, 1). */
next(): number {
this._state = (this._state + 0x6d2b79f5) >>> 0;
let t = this._state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
/** Uniform integer in [0, bound). `bound` must be positive. */
nextInt(bound: number): number {
if (bound <= 0) {
throw new RangeError(`Rng.nextInt bound must be positive, got ${bound}`);
}
return Math.floor(this.next() * bound);
}
/** Uniformly picks one element. Throws on an empty array. */
pick<T>(items: readonly T[]): T {
if (items.length === 0) {
throw new RangeError('Rng.pick called on an empty array');
}
return items[this.nextInt(items.length)];
}
/** Fisher-Yates, in place. Returns the same array for convenience. */
shuffle<T>(items: T[]): T[] {
for (let i = items.length - 1; i > 0; i--) {
const j = this.nextInt(i + 1);
const tmp = items[i];
items[i] = items[j];
items[j] = tmp;
}
return items;
}
}