aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-04-22 06:51:57 +0530
committerBobby <[email protected]>2026-04-22 06:51:57 +0530
commit4dfbf7a13120b3b64521fce6de7d5d3f76cd2084 (patch)
tree351d2ab064442e29e76a74c7b4fa53c1048fb66b
parent4383213a23e27903e1fed270f1dbcc116644c7fc (diff)
downloadhollowdark-4dfbf7a13120b3b64521fce6de7d5d3f76cd2084.tar.xz
hollowdark-4dfbf7a13120b3b64521fce6de7d5d3f76cd2084.zip
Add shared utilities: Result, assert, deepEqual, common types, log
Foundational helpers referenced by most subsequent code. Small, pure, no dependencies on anything in the project. utils/result.ts Result<T, E> = Ok<T> | Err<E> for recoverable failures at system boundaries (save/load, manifest fetch, content validation). Internal pure logic still uses throw for programmer errors. ok / err / isOk / isErr / mapResult / mapErr / unwrap / unwrapOr. utils/assert.ts assert (with type-narrowing asserts), assertNever for exhaustive-switch termination, assertDefined for narrowing T | null | undefined → T. utils/equal.ts Structural deepEqual for tests comparing simulation snapshots. Handles plain objects, arrays, Map, Set, Date, primitives. Does not handle cyclic graphs (simulation state is a tree). utils/types.ts Common type aliases: NonEmptyArray, JsonValue, ElementOf, DeepReadonly, Brand<T, B> for nominal / branded types (PersonId vs RelationshipId). utils/log.ts Structured logging wrapping console. Developer- facing only; the game ships with no runtime telemetry. utils/index.ts Public re-exports. 40 unit tests in tests/unit/utils/ cover Result constructors and combinators, assert variants, and deepEqual's primitive / array / object / Map / Set / Date paths plus a realistic simulation-snapshot comparison.
-rw-r--r--tests/unit/utils/assert.test.ts46
-rw-r--r--tests/unit/utils/equal.test.ts147
-rw-r--r--tests/unit/utils/result.test.ts83
-rw-r--r--utils/assert.ts36
-rw-r--r--utils/equal.ts55
-rw-r--r--utils/index.ts21
-rw-r--r--utils/log.ts35
-rw-r--r--utils/result.ts53
-rw-r--r--utils/types.ts37
9 files changed, 513 insertions, 0 deletions
diff --git a/tests/unit/utils/assert.test.ts b/tests/unit/utils/assert.test.ts
new file mode 100644
index 0000000..c1fc02d
--- /dev/null
+++ b/tests/unit/utils/assert.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, test } from 'vitest'
+import { assert, assertDefined, assertNever } from 'utils/assert'
+
+describe('assert', () => {
+ test('passes on truthy condition', () => {
+ expect(() => assert(true)).not.toThrow()
+ expect(() => assert(1)).not.toThrow()
+ expect(() => assert('non-empty')).not.toThrow()
+ })
+
+ test('throws on falsy condition', () => {
+ expect(() => assert(false)).toThrow(/Assertion failed/)
+ expect(() => assert(0)).toThrow()
+ expect(() => assert(null)).toThrow()
+ expect(() => assert(undefined)).toThrow()
+ expect(() => assert('')).toThrow()
+ })
+
+ test('uses supplied message', () => {
+ expect(() => assert(false, 'custom reason')).toThrow(/custom reason/)
+ })
+})
+
+describe('assertDefined', () => {
+ test('returns the value when defined', () => {
+ expect(assertDefined(5)).toBe(5)
+ expect(assertDefined('')).toBe('')
+ expect(assertDefined(false)).toBe(false)
+ expect(assertDefined(0)).toBe(0)
+ })
+
+ test('throws on null / undefined', () => {
+ expect(() => assertDefined(null)).toThrow()
+ expect(() => assertDefined(undefined)).toThrow()
+ })
+
+ test('uses supplied message', () => {
+ expect(() => assertDefined(null, 'nope')).toThrow(/nope/)
+ })
+})
+
+describe('assertNever', () => {
+ test('always throws', () => {
+ expect(() => assertNever('x' as never)).toThrow()
+ })
+})
diff --git a/tests/unit/utils/equal.test.ts b/tests/unit/utils/equal.test.ts
new file mode 100644
index 0000000..8f07996
--- /dev/null
+++ b/tests/unit/utils/equal.test.ts
@@ -0,0 +1,147 @@
+import { describe, expect, test } from 'vitest'
+import { deepEqual } from 'utils/equal'
+
+describe('deepEqual — primitives', () => {
+ test('identical primitives', () => {
+ expect(deepEqual(1, 1)).toBe(true)
+ expect(deepEqual('a', 'a')).toBe(true)
+ expect(deepEqual(true, true)).toBe(true)
+ expect(deepEqual(null, null)).toBe(true)
+ expect(deepEqual(undefined, undefined)).toBe(true)
+ })
+
+ test('different primitives', () => {
+ expect(deepEqual(1, 2)).toBe(false)
+ expect(deepEqual('a', 'b')).toBe(false)
+ expect(deepEqual(null, undefined)).toBe(false)
+ })
+
+ test('NaN is treated as equal to NaN (Object.is semantics)', () => {
+ expect(deepEqual(NaN, NaN)).toBe(true)
+ })
+
+ test('+0 and -0 are distinct (Object.is semantics)', () => {
+ expect(deepEqual(0, -0)).toBe(false)
+ })
+})
+
+describe('deepEqual — arrays', () => {
+ test('same-length equal arrays', () => {
+ expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true)
+ })
+
+ test('different-length arrays', () => {
+ expect(deepEqual([1, 2], [1, 2, 3])).toBe(false)
+ })
+
+ test('different elements', () => {
+ expect(deepEqual([1, 2, 3], [1, 2, 4])).toBe(false)
+ })
+
+ test('nested arrays', () => {
+ expect(
+ deepEqual(
+ [
+ [1, 2],
+ [3, 4]
+ ],
+ [
+ [1, 2],
+ [3, 4]
+ ]
+ )
+ ).toBe(true)
+ })
+
+ test('array vs non-array', () => {
+ expect(deepEqual([1], { 0: 1, length: 1 })).toBe(false)
+ })
+})
+
+describe('deepEqual — objects', () => {
+ test('same-shape objects', () => {
+ expect(deepEqual({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true)
+ })
+
+ test('order of keys does not matter', () => {
+ expect(deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true)
+ })
+
+ test('missing key on one side', () => {
+ expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false)
+ })
+
+ test('nested objects', () => {
+ expect(deepEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } })).toBe(true)
+ expect(deepEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 2 } } })).toBe(false)
+ })
+})
+
+describe('deepEqual — Map', () => {
+ test('equal maps', () => {
+ expect(
+ deepEqual(
+ new Map([
+ ['a', 1],
+ ['b', 2]
+ ]),
+ new Map([
+ ['a', 1],
+ ['b', 2]
+ ])
+ )
+ ).toBe(true)
+ })
+
+ test('different sizes', () => {
+ expect(deepEqual(new Map([['a', 1]]), new Map([['a', 1], ['b', 2]]))).toBe(false)
+ })
+
+ test('different values', () => {
+ expect(deepEqual(new Map([['a', 1]]), new Map([['a', 2]]))).toBe(false)
+ })
+})
+
+describe('deepEqual — Set', () => {
+ test('equal sets', () => {
+ expect(deepEqual(new Set([1, 2, 3]), new Set([3, 2, 1]))).toBe(true)
+ })
+
+ test('different sets', () => {
+ expect(deepEqual(new Set([1, 2]), new Set([1, 2, 3]))).toBe(false)
+ })
+})
+
+describe('deepEqual — Date', () => {
+ test('same timestamp is equal', () => {
+ expect(deepEqual(new Date(1000), new Date(1000))).toBe(true)
+ })
+
+ test('different timestamps', () => {
+ expect(deepEqual(new Date(1000), new Date(2000))).toBe(false)
+ })
+
+ test('Date vs non-Date', () => {
+ expect(deepEqual(new Date(0), 0)).toBe(false)
+ })
+})
+
+describe('deepEqual — mixed', () => {
+ test('deep structure typical of simulation state', () => {
+ const a = {
+ id: 'p1',
+ name: { given: 'Meera', surname: 'Sarwath' },
+ traits: new Map([['openness', 72]]),
+ friends: new Set(['p2', 'p3']),
+ events: [{ t: 10, kind: 'birth' }]
+ }
+ const b = {
+ id: 'p1',
+ name: { given: 'Meera', surname: 'Sarwath' },
+ traits: new Map([['openness', 72]]),
+ friends: new Set(['p3', 'p2']),
+ events: [{ t: 10, kind: 'birth' }]
+ }
+ expect(deepEqual(a, b)).toBe(true)
+ })
+})
diff --git a/tests/unit/utils/result.test.ts b/tests/unit/utils/result.test.ts
new file mode 100644
index 0000000..ef33e74
--- /dev/null
+++ b/tests/unit/utils/result.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, test } from 'vitest'
+import {
+ err,
+ isErr,
+ isOk,
+ mapErr,
+ mapResult,
+ ok,
+ unwrap,
+ unwrapOr,
+ type Result
+} from 'utils/result'
+
+describe('Result constructors and predicates', () => {
+ test('ok wraps a value', () => {
+ const r = ok(42)
+ expect(r).toEqual({ ok: true, value: 42 })
+ expect(isOk(r)).toBe(true)
+ expect(isErr(r)).toBe(false)
+ })
+
+ test('err wraps an error', () => {
+ const e = new Error('boom')
+ const r = err(e)
+ expect(r).toEqual({ ok: false, error: e })
+ expect(isErr(r)).toBe(true)
+ expect(isOk(r)).toBe(false)
+ })
+})
+
+describe('mapResult / mapErr', () => {
+ test('mapResult transforms the value of an ok', () => {
+ const r: Result<number> = ok(3)
+ expect(mapResult(r, (n) => n * 2)).toEqual(ok(6))
+ })
+
+ test('mapResult leaves an error untouched', () => {
+ const e = new Error('x')
+ const r: Result<number> = err(e)
+ expect(mapResult(r, (n: number) => n * 2)).toEqual(err(e))
+ })
+
+ test('mapErr transforms the error of a failure', () => {
+ const r: Result<number, string> = err('oops')
+ expect(mapErr(r, (s: string) => s.length)).toEqual(err(4))
+ })
+
+ test('mapErr leaves an ok untouched', () => {
+ const r: Result<number, string> = ok(5)
+ expect(mapErr(r, (s: string) => s.length)).toEqual(ok(5))
+ })
+})
+
+describe('unwrap / unwrapOr', () => {
+ test('unwrap returns the value when ok', () => {
+ expect(unwrap(ok('hello'))).toBe('hello')
+ })
+
+ test('unwrap throws when error', () => {
+ expect(() => unwrap(err(new Error('bad')))).toThrow(/bad/)
+ })
+
+ test('unwrapOr returns value when ok', () => {
+ expect(unwrapOr(ok(1), 0)).toBe(1)
+ })
+
+ test('unwrapOr returns fallback when error', () => {
+ expect(unwrapOr(err<Error>(new Error('e')), 0)).toBe(0)
+ })
+})
+
+describe('type-narrowing', () => {
+ test('isOk narrows to Ok<T>', () => {
+ const r: Result<number, string> = ok(10)
+ if (isOk(r)) {
+ // Type-level check — if isOk doesn't narrow, this line fails to compile.
+ const n: number = r.value
+ expect(n).toBe(10)
+ } else {
+ expect.fail('isOk should narrow to ok branch')
+ }
+ })
+})
diff --git a/utils/assert.ts b/utils/assert.ts
new file mode 100644
index 0000000..05de651
--- /dev/null
+++ b/utils/assert.ts
@@ -0,0 +1,36 @@
+/**
+ * assert — throw if a condition is false, narrow the type otherwise.
+ * For programmer errors (invariants that should never fail); use
+ * Result<T, E> for recoverable failures.
+ */
+export function assert(condition: unknown, message?: string): asserts condition {
+ if (!condition) {
+ throw new Error(message ?? 'Assertion failed')
+ }
+}
+
+/**
+ * Mark a branch as unreachable in an exhaustive switch. If the compiler
+ * ever allows this to be reached, `value` stops being `never` and the
+ * call site fails to type-check.
+ *
+ * Usage:
+ * switch (kind) {
+ * case 'a': ...
+ * case 'b': ...
+ * default: assertNever(kind)
+ * }
+ */
+export function assertNever(value: never, message?: string): never {
+ throw new Error(message ?? `Unhandled case: ${String(value)}`)
+}
+
+/**
+ * Throw if a value is null or undefined; return the narrowed value.
+ */
+export function assertDefined<T>(value: T, message?: string): NonNullable<T> {
+ if (value === null || value === undefined) {
+ throw new Error(message ?? 'Expected value to be defined')
+ }
+ return value as NonNullable<T>
+}
diff --git a/utils/equal.ts b/utils/equal.ts
new file mode 100644
index 0000000..8e12216
--- /dev/null
+++ b/utils/equal.ts
@@ -0,0 +1,55 @@
+/**
+ * Structural deep equality, used by determinism tests to compare
+ * simulation snapshots. Handles plain objects, arrays, Maps, Sets, Dates,
+ * and primitives. Does not handle class instances with custom equality
+ * or cyclic object graphs — simulation state is a tree, not a graph.
+ */
+export function deepEqual(a: unknown, b: unknown): boolean {
+ if (Object.is(a, b)) return true
+
+ if (typeof a !== typeof b) return false
+ if (a === null || b === null) return false
+ if (typeof a !== 'object') return false
+
+ if (a instanceof Date || b instanceof Date) {
+ return a instanceof Date && b instanceof Date && a.getTime() === b.getTime()
+ }
+
+ if (Array.isArray(a) || Array.isArray(b)) {
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
+ if (a.length !== b.length) return false
+ for (let i = 0; i < a.length; i++) {
+ if (!deepEqual(a[i], b[i])) return false
+ }
+ return true
+ }
+
+ if (a instanceof Map || b instanceof Map) {
+ if (!(a instanceof Map) || !(b instanceof Map)) return false
+ if (a.size !== b.size) return false
+ for (const [key, value] of a) {
+ if (!b.has(key) || !deepEqual(value, b.get(key))) return false
+ }
+ return true
+ }
+
+ if (a instanceof Set || b instanceof Set) {
+ if (!(a instanceof Set) || !(b instanceof Set)) return false
+ if (a.size !== b.size) return false
+ for (const value of a) {
+ if (!b.has(value)) return false
+ }
+ return true
+ }
+
+ const aObj = a as Record<string, unknown>
+ const bObj = b as Record<string, unknown>
+ const aKeys = Object.keys(aObj)
+ const bKeys = Object.keys(bObj)
+ if (aKeys.length !== bKeys.length) return false
+ for (const key of aKeys) {
+ if (!Object.prototype.hasOwnProperty.call(bObj, key)) return false
+ if (!deepEqual(aObj[key], bObj[key])) return false
+ }
+ return true
+}
diff --git a/utils/index.ts b/utils/index.ts
new file mode 100644
index 0000000..4449e3e
--- /dev/null
+++ b/utils/index.ts
@@ -0,0 +1,21 @@
+export {
+ err,
+ isErr,
+ isOk,
+ mapErr,
+ mapResult,
+ ok,
+ unwrap,
+ unwrapOr,
+ type Err,
+ type Ok,
+ type Result
+} from 'utils/result'
+
+export { assert, assertDefined, assertNever } from 'utils/assert'
+
+export { deepEqual } from 'utils/equal'
+
+export type { Brand, DeepReadonly, ElementOf, JsonValue, NonEmptyArray } from 'utils/types'
+
+export { log } from 'utils/log'
diff --git a/utils/log.ts b/utils/log.ts
new file mode 100644
index 0000000..01e23f3
--- /dev/null
+++ b/utils/log.ts
@@ -0,0 +1,35 @@
+/**
+ * Structured logging. Uses console internally but carries a consistent
+ * message-plus-context shape so downstream sinks can filter and format.
+ * The game ships with no runtime telemetry — logs are developer-facing
+ * only, visible in the browser devtools.
+ */
+
+type LogLevel = 'debug' | 'info' | 'warn' | 'error'
+
+function emit(level: LogLevel, message: string, context?: Record<string, unknown>): void {
+ const payload = context ? `${message} ${JSON.stringify(context)}` : message
+ switch (level) {
+ case 'debug':
+ console.log(payload)
+ return
+ case 'info':
+ console.info(payload)
+ return
+ case 'warn':
+ console.warn(payload)
+ return
+ case 'error':
+ console.error(payload)
+ return
+ }
+}
+
+export const log = {
+ debug: (message: string, context?: Record<string, unknown>): void =>
+ emit('debug', message, context),
+ info: (message: string, context?: Record<string, unknown>): void => emit('info', message, context),
+ warn: (message: string, context?: Record<string, unknown>): void => emit('warn', message, context),
+ error: (message: string, context?: Record<string, unknown>): void =>
+ emit('error', message, context)
+} as const
diff --git a/utils/result.ts b/utils/result.ts
new file mode 100644
index 0000000..cfe487f
--- /dev/null
+++ b/utils/result.ts
@@ -0,0 +1,53 @@
+/**
+ * Result<T, E> — a recoverable-error return type for functions that can
+ * fail without throwing. Used at system boundaries (save/load, content
+ * validation, manifest fetch) where the caller needs to decide whether a
+ * failure is fatal. Internal pure logic uses plain returns and throws
+ * Error for programmer errors (rules/01-code-style.md).
+ */
+
+export type Ok<T> = { readonly ok: true; readonly value: T }
+export type Err<E> = { readonly ok: false; readonly error: E }
+export type Result<T, E = Error> = Ok<T> | Err<E>
+
+export function ok<T>(value: T): Ok<T> {
+ return { ok: true, value }
+}
+
+export function err<E>(error: E): Err<E> {
+ return { ok: false, error }
+}
+
+export function isOk<T, E>(r: Result<T, E>): r is Ok<T> {
+ return r.ok
+}
+
+export function isErr<T, E>(r: Result<T, E>): r is Err<E> {
+ return !r.ok
+}
+
+/** Map the value of an ok result; pass errors through unchanged. */
+export function mapResult<T, U, E>(r: Result<T, E>, f: (value: T) => U): Result<U, E> {
+ return r.ok ? ok(f(r.value)) : r
+}
+
+/** Map the error of a failed result; pass values through unchanged. */
+export function mapErr<T, E, F>(r: Result<T, E>, f: (error: E) => F): Result<T, F> {
+ return r.ok ? r : err(f(r.error))
+}
+
+/**
+ * Extract the value from an ok result; throw on error. Reserve for tests
+ * and assertion contexts — in production code prefer branching on isOk.
+ */
+export function unwrap<T, E>(r: Result<T, E>): T {
+ if (!r.ok) {
+ throw new Error(`unwrap() called on error result: ${String(r.error)}`)
+ }
+ return r.value
+}
+
+/** Extract the value or return a default. */
+export function unwrapOr<T, E>(r: Result<T, E>, fallback: T): T {
+ return r.ok ? r.value : fallback
+}
diff --git a/utils/types.ts b/utils/types.ts
new file mode 100644
index 0000000..a9629c8
--- /dev/null
+++ b/utils/types.ts
@@ -0,0 +1,37 @@
+/** A non-empty readonly array; the type system guarantees index 0 exists. */
+export type NonEmptyArray<T> = readonly [T, ...T[]]
+
+/** Values that round-trip through JSON.stringify / JSON.parse. */
+export type JsonValue =
+ | string
+ | number
+ | boolean
+ | null
+ | readonly JsonValue[]
+ | { readonly [key: string]: JsonValue | undefined }
+
+/** Element type of a readonly array (e.g., `ElementOf<typeof MONTH_NAMES>`). */
+export type ElementOf<T extends readonly unknown[]> = T extends readonly (infer E)[] ? E : never
+
+/** Deep-readonly version of a structural type. */
+export type DeepReadonly<T> = T extends (infer U)[]
+ ? ReadonlyArray<DeepReadonly<U>>
+ : T extends Map<infer K, infer V>
+ ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
+ : T extends Set<infer U>
+ ? ReadonlySet<DeepReadonly<U>>
+ : T extends object
+ ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
+ : T
+
+/**
+ * Nominal / branded type: compile-time-only tag to distinguish otherwise
+ * structurally-equal types.
+ *
+ * type PersonId = Brand<string, 'PersonId'>
+ * type RelId = Brand<string, 'RelationshipId'>
+ *
+ * PersonId and RelId are still strings at runtime but aren't assignable
+ * to each other without an explicit cast.
+ */
+export type Brand<T, B extends string> = T & { readonly __brand: B }