diff options
| author | Bobby <[email protected]> | 2026-04-22 07:04:29 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-04-22 07:04:29 +0530 |
| commit | 9f0c3b0341d3ad29f3201d56fff5f19662a6f0b6 (patch) | |
| tree | e53e1adb1a87b8a0faa4ee9a808155c21c0ab83c | |
| parent | 4dfbf7a13120b3b64521fce6de7d5d3f76cd2084 (diff) | |
| download | hollowdark-9f0c3b0341d3ad29f3201d56fff5f19662a6f0b6.tar.xz hollowdark-9f0c3b0341d3ad29f3201d56fff5f19662a6f0b6.zip | |
Use relative imports for same-directory siblings; split utils into folders
Two related cleanups landed together because they touch the same pattern:
1. Intra-module imports now use relative paths rather than the module's
own path alias. rng/seeded.ts imports from './derive', not 'rng/derive';
time/gameTime.ts from './calendar', not 'time/calendar'; the utils
barrel from './result' etc. This matches rules/01-code-style.md:
"Relative imports only for same-directory siblings." The alias form
is reserved for imports *between* modules — how external callers refer
to a module's public surface.
(The TS language server in the IDE had trouble resolving the self-
aliased form even though svelte-check accepted it; this change
removes the ambiguity.)
2. utils/ is now folder-per-concept. Each utility owns a directory with
its files broken up into small pieces:
utils/result/ types, constructors, predicates, map, unwrap
utils/assert/ assert, assert-never, assert-defined
utils/equal/ deep
utils/types/ brand, deep-readonly, element-of, json, non-empty-array
utils/log/ log
utils/index.ts re-exports from each subfolder via the barrel. External
callers import from 'utils' unchanged; internal references are relative.
One reason to prefer folders over flat files here is headroom — when a
concept grows, new files sit alongside their siblings inside the
concept's folder rather than crowding the utils/ root.
No API changes. All 128 tests still green.
| -rw-r--r-- | rng/index.ts | 4 | ||||
| -rw-r--r-- | rng/seeded.ts | 2 | ||||
| -rw-r--r-- | time/gameTime.ts | 2 | ||||
| -rw-r--r-- | time/index.ts | 8 | ||||
| -rw-r--r-- | utils/assert.ts | 36 | ||||
| -rw-r--r-- | utils/assert/assert-defined.ts | 7 | ||||
| -rw-r--r-- | utils/assert/assert-never.ts | 16 | ||||
| -rw-r--r-- | utils/assert/assert.ts | 10 | ||||
| -rw-r--r-- | utils/assert/index.ts | 3 | ||||
| -rw-r--r-- | utils/equal/deep.ts (renamed from utils/equal.ts) | 4 | ||||
| -rw-r--r-- | utils/equal/index.ts | 1 | ||||
| -rw-r--r-- | utils/index.ts | 26 | ||||
| -rw-r--r-- | utils/log/index.ts | 1 | ||||
| -rw-r--r-- | utils/log/log.ts (renamed from utils/log.ts) | 14 | ||||
| -rw-r--r-- | utils/result.ts | 53 | ||||
| -rw-r--r-- | utils/result/constructors.ts | 9 | ||||
| -rw-r--r-- | utils/result/index.ts | 5 | ||||
| -rw-r--r-- | utils/result/map.ts | 12 | ||||
| -rw-r--r-- | utils/result/predicates.ts | 9 | ||||
| -rw-r--r-- | utils/result/types.ts | 14 | ||||
| -rw-r--r-- | utils/result/unwrap.ts | 17 | ||||
| -rw-r--r-- | utils/types.ts | 37 | ||||
| -rw-r--r-- | utils/types/brand.ts | 11 | ||||
| -rw-r--r-- | utils/types/deep-readonly.ts | 10 | ||||
| -rw-r--r-- | utils/types/element-of.ts | 2 | ||||
| -rw-r--r-- | utils/types/index.ts | 5 | ||||
| -rw-r--r-- | utils/types/json.ts | 8 | ||||
| -rw-r--r-- | utils/types/non-empty-array.ts | 2 |
28 files changed, 165 insertions, 163 deletions
diff --git a/rng/index.ts b/rng/index.ts index fbc4251..b01e0dd 100644 --- a/rng/index.ts +++ b/rng/index.ts @@ -1,2 +1,2 @@ -export { createRNG, type SeededRNG } from 'rng/seeded' -export { hashString, deriveSeed } from 'rng/derive' +export { createRNG, type SeededRNG } from './seeded' +export { hashString, deriveSeed } from './derive' diff --git a/rng/seeded.ts b/rng/seeded.ts index 8aff2fb..52c4719 100644 --- a/rng/seeded.ts +++ b/rng/seeded.ts @@ -1,4 +1,4 @@ -import { deriveSeed, hashString } from 'rng/derive' +import { deriveSeed, hashString } from './derive' /** * Seeded PRNG. All gameplay randomness routes through this interface — diff --git a/time/gameTime.ts b/time/gameTime.ts index 7e56260..2f2fed7 100644 --- a/time/gameTime.ts +++ b/time/gameTime.ts @@ -8,7 +8,7 @@ import { REGULAR_MONTH_COUNT, daysInMonth, monthName -} from 'time/calendar' +} from './calendar' /** * A position in game time. Immutable — all arithmetic returns a new value. diff --git a/time/index.ts b/time/index.ts index 34a518d..e111b0d 100644 --- a/time/index.ts +++ b/time/index.ts @@ -13,7 +13,7 @@ export { monthSeason, type MonthName, type Season -} from 'time/calendar' +} from './calendar' export { addDays, @@ -32,7 +32,7 @@ export { toAbsoluteDays, weeksBetween, type GameTime -} from 'time/gameTime' +} from './gameTime' export { TICK_UNIT_BY_LIFE_STAGE, @@ -40,6 +40,6 @@ export { tickUnitForAge, type LifeStage, type TickUnit -} from 'time/granularity' +} from './granularity' -export { SPEEDS, type Speed } from 'time/speed' +export { SPEEDS, type Speed } from './speed' diff --git a/utils/assert.ts b/utils/assert.ts deleted file mode 100644 index 05de651..0000000 --- a/utils/assert.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * 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/assert/assert-defined.ts b/utils/assert/assert-defined.ts new file mode 100644 index 0000000..87af0e5 --- /dev/null +++ b/utils/assert/assert-defined.ts @@ -0,0 +1,7 @@ +/** 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/assert/assert-never.ts b/utils/assert/assert-never.ts new file mode 100644 index 0000000..406eca9 --- /dev/null +++ b/utils/assert/assert-never.ts @@ -0,0 +1,16 @@ +/** + * Mark a branch as unreachable in an exhaustive switch. If the compiler + * ever allows this line to be reached, `value` stops being `never` and + * the call site fails to type-check — making exhaustive checks load-bearing + * at compile time. + * + * 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)}`) +} diff --git a/utils/assert/assert.ts b/utils/assert/assert.ts new file mode 100644 index 0000000..1fd70c8 --- /dev/null +++ b/utils/assert/assert.ts @@ -0,0 +1,10 @@ +/** + * Throw if a condition is false, narrow the type to truthy 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') + } +} diff --git a/utils/assert/index.ts b/utils/assert/index.ts new file mode 100644 index 0000000..a3df835 --- /dev/null +++ b/utils/assert/index.ts @@ -0,0 +1,3 @@ +export { assert } from './assert' +export { assertDefined } from './assert-defined' +export { assertNever } from './assert-never' diff --git a/utils/equal.ts b/utils/equal/deep.ts index 8e12216..d2c6ce2 100644 --- a/utils/equal.ts +++ b/utils/equal/deep.ts @@ -1,8 +1,8 @@ /** * 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. + * 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 diff --git a/utils/equal/index.ts b/utils/equal/index.ts new file mode 100644 index 0000000..3a937a8 --- /dev/null +++ b/utils/equal/index.ts @@ -0,0 +1 @@ +export { deepEqual } from './deep' diff --git a/utils/index.ts b/utils/index.ts index 4449e3e..436365f 100644 --- a/utils/index.ts +++ b/utils/index.ts @@ -1,21 +1,5 @@ -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' +export * from './assert' +export * from './equal' +export * from './log' +export * from './result' +export * from './types' diff --git a/utils/log/index.ts b/utils/log/index.ts new file mode 100644 index 0000000..62d96a1 --- /dev/null +++ b/utils/log/index.ts @@ -0,0 +1 @@ +export { log } from './log' diff --git a/utils/log.ts b/utils/log/log.ts index 01e23f3..1b2cfec 100644 --- a/utils/log.ts +++ b/utils/log/log.ts @@ -11,16 +11,16 @@ function emit(level: LogLevel, message: string, context?: Record<string, unknown const payload = context ? `${message} ${JSON.stringify(context)}` : message switch (level) { case 'debug': - console.log(payload) + console.log(payload) return case 'info': - console.info(payload) + console.info(payload) return case 'warn': - console.warn(payload) + console.warn(payload) return case 'error': - console.error(payload) + console.error(payload) return } } @@ -28,8 +28,10 @@ function emit(level: LogLevel, message: string, context?: Record<string, unknown 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), + 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 deleted file mode 100644 index cfe487f..0000000 --- a/utils/result.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 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/result/constructors.ts b/utils/result/constructors.ts new file mode 100644 index 0000000..b0ed958 --- /dev/null +++ b/utils/result/constructors.ts @@ -0,0 +1,9 @@ +import type { Err, Ok } from './types' + +export function ok<T>(value: T): Ok<T> { + return { ok: true, value } +} + +export function err<E>(error: E): Err<E> { + return { ok: false, error } +} diff --git a/utils/result/index.ts b/utils/result/index.ts new file mode 100644 index 0000000..931b638 --- /dev/null +++ b/utils/result/index.ts @@ -0,0 +1,5 @@ +export type { Err, Ok, Result } from './types' +export { err, ok } from './constructors' +export { isErr, isOk } from './predicates' +export { mapErr, mapResult } from './map' +export { unwrap, unwrapOr } from './unwrap' diff --git a/utils/result/map.ts b/utils/result/map.ts new file mode 100644 index 0000000..9cf44a1 --- /dev/null +++ b/utils/result/map.ts @@ -0,0 +1,12 @@ +import { err, ok } from './constructors' +import type { Result } from './types' + +/** 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)) +} diff --git a/utils/result/predicates.ts b/utils/result/predicates.ts new file mode 100644 index 0000000..315eb0d --- /dev/null +++ b/utils/result/predicates.ts @@ -0,0 +1,9 @@ +import type { Err, Ok, Result } from './types' + +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 +} diff --git a/utils/result/types.ts b/utils/result/types.ts new file mode 100644 index 0000000..ee95c08 --- /dev/null +++ b/utils/result/types.ts @@ -0,0 +1,14 @@ +/** A successful result carrying a value. */ +export type Ok<T> = { readonly ok: true; readonly value: T } + +/** A failed result carrying an error. */ +export type Err<E> = { readonly ok: false; readonly error: E } + +/** + * 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 Result<T, E = Error> = Ok<T> | Err<E> diff --git a/utils/result/unwrap.ts b/utils/result/unwrap.ts new file mode 100644 index 0000000..84e658e --- /dev/null +++ b/utils/result/unwrap.ts @@ -0,0 +1,17 @@ +import type { Result } from './types' + +/** + * 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 fallback. */ +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 deleted file mode 100644 index a9629c8..0000000 --- a/utils/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** 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 } diff --git a/utils/types/brand.ts b/utils/types/brand.ts new file mode 100644 index 0000000..d1b7977 --- /dev/null +++ b/utils/types/brand.ts @@ -0,0 +1,11 @@ +/** + * 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 } diff --git a/utils/types/deep-readonly.ts b/utils/types/deep-readonly.ts new file mode 100644 index 0000000..dddb6d6 --- /dev/null +++ b/utils/types/deep-readonly.ts @@ -0,0 +1,10 @@ +/** 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 diff --git a/utils/types/element-of.ts b/utils/types/element-of.ts new file mode 100644 index 0000000..fcb144d --- /dev/null +++ b/utils/types/element-of.ts @@ -0,0 +1,2 @@ +/** 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 diff --git a/utils/types/index.ts b/utils/types/index.ts new file mode 100644 index 0000000..cf55ea7 --- /dev/null +++ b/utils/types/index.ts @@ -0,0 +1,5 @@ +export type { Brand } from './brand' +export type { DeepReadonly } from './deep-readonly' +export type { ElementOf } from './element-of' +export type { JsonValue } from './json' +export type { NonEmptyArray } from './non-empty-array' diff --git a/utils/types/json.ts b/utils/types/json.ts new file mode 100644 index 0000000..0aa12c8 --- /dev/null +++ b/utils/types/json.ts @@ -0,0 +1,8 @@ +/** Values that round-trip through JSON.stringify / JSON.parse. */ +export type JsonValue = + | string + | number + | boolean + | null + | readonly JsonValue[] + | { readonly [key: string]: JsonValue | undefined } diff --git a/utils/types/non-empty-array.ts b/utils/types/non-empty-array.ts new file mode 100644 index 0000000..59a6160 --- /dev/null +++ b/utils/types/non-empty-array.ts @@ -0,0 +1,2 @@ +/** A non-empty readonly array; the type system guarantees index 0 exists. */ +export type NonEmptyArray<T> = readonly [T, ...T[]] |
