aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-04-22 06:48:46 +0530
committerBobby <[email protected]>2026-04-22 06:48:46 +0530
commit4383213a23e27903e1fed270f1dbcc116644c7fc (patch)
treec5920b6ca2d7faf7f41e29694c7a54956f213c07
parent47381ca2cd6dec22848b66924d9558a191e47218 (diff)
downloadhollowdark-4383213a23e27903e1fed270f1dbcc116644c7fc.tar.xz
hollowdark-4383213a23e27903e1fed270f1dbcc116644c7fc.zip
Implement GameTime and the 12-month calendar
The Hollowdark calendar is 12 × 30-day months + a 5-day year-end festival = 365 days, with 7-day weeks (docs/01-world.md and the style bible). Months live in canonical order: spring 1 Thawing 2 Greening 3 Blossomtide summer 4 Highsun 5 Amberhaze 6 Harvestmark autumn 7 Firstfall 8 Stormturn 9 Ashfall winter 10 Rainfall 11 Hollowdark 12 Rimefrost festival 13 Year's End Festival (5 days) time/calendar.ts month names, season mapping, days-per-month, festival helpers time/gameTime.ts GameTime shape + arithmetic: makeGameTime, addDays / addWeeks / addMonths / addYears, daysBetween / weeksBetween, compare / isBefore / isAfter / isSameDay, dayOfWeek, dayOfYear, toAbsoluteDays, formatGameTime time/granularity.ts LifeStage + TickUnit mapping — one tick is a year in infancy, a season in childhood, a month in adolescence and old age, a week in adult life (docs/05-time-system.md, ARCHITECTURE.md §5) time/speed.ts Speed type: 'paused' | 'play' | 'fast' time/index.ts public re-exports Arithmetic is implemented by flattening GameTime to absolute-day integers (year × 365 + dayOfYear - 1) so addDays, daysBetween, and ordering are exact integer math. addMonths uses 13-month modular arithmetic and clamps the day into the festival's 5-day length on overflow. 59 unit tests in tests/unit/time/ cover constants, validation, arithmetic edge cases (Rimefrost 30 → Festival 1, Festival 5 → Thawing 1 of next year, day-clamping when landing in the festival), negative offsets, tickOfDay preservation, day-of-week stability, and life-stage boundaries.
-rw-r--r--tests/unit/time/calendar.test.ts81
-rw-r--r--tests/unit/time/gameTime.test.ts294
-rw-r--r--tests/unit/time/granularity.test.ts70
-rw-r--r--time/calendar.ts86
-rw-r--r--time/gameTime.ts183
-rw-r--r--time/granularity.ts47
-rw-r--r--time/index.ts45
-rw-r--r--time/speed.ts11
8 files changed, 817 insertions, 0 deletions
diff --git a/tests/unit/time/calendar.test.ts b/tests/unit/time/calendar.test.ts
new file mode 100644
index 0000000..1196972
--- /dev/null
+++ b/tests/unit/time/calendar.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, test } from 'vitest'
+import {
+ DAYS_PER_YEAR,
+ FESTIVAL_DAYS,
+ FESTIVAL_MONTH,
+ MONTH_NAMES,
+ daysInMonth,
+ isFestival,
+ monthName,
+ monthSeason
+} from 'time/calendar'
+
+describe('calendar constants', () => {
+ test('year is 365 days', () => {
+ expect(DAYS_PER_YEAR).toBe(365)
+ })
+
+ test('festival is 5 days', () => {
+ expect(FESTIVAL_DAYS).toBe(5)
+ expect(daysInMonth(FESTIVAL_MONTH)).toBe(5)
+ })
+
+ test('regular months are 30 days each', () => {
+ for (let m = 1; m <= 12; m++) {
+ expect(daysInMonth(m)).toBe(30)
+ }
+ })
+
+ test('month count including festival is 13', () => {
+ expect(MONTH_NAMES).toHaveLength(13)
+ })
+})
+
+describe('monthName', () => {
+ test('spring months', () => {
+ expect(monthName(1)).toBe('Thawing')
+ expect(monthName(2)).toBe('Greening')
+ expect(monthName(3)).toBe('Blossomtide')
+ })
+
+ test('summer, autumn, winter months', () => {
+ expect(monthName(4)).toBe('Highsun')
+ expect(monthName(7)).toBe('Firstfall')
+ expect(monthName(12)).toBe('Rimefrost')
+ })
+
+ test('festival is month 13', () => {
+ expect(monthName(13)).toBe("Year's End Festival")
+ expect(isFestival(13)).toBe(true)
+ expect(isFestival(12)).toBe(false)
+ })
+
+ test('rejects out-of-range months', () => {
+ expect(() => monthName(0)).toThrow()
+ expect(() => monthName(14)).toThrow()
+ expect(() => monthName(1.5)).toThrow()
+ })
+})
+
+describe('monthSeason', () => {
+ test('each season has three months', () => {
+ const seasons = new Map<string, number>()
+ for (let m = 1; m <= 13; m++) {
+ const s = monthSeason(m)
+ seasons.set(s, (seasons.get(s) ?? 0) + 1)
+ }
+ expect(seasons.get('spring')).toBe(3)
+ expect(seasons.get('summer')).toBe(3)
+ expect(seasons.get('autumn')).toBe(3)
+ expect(seasons.get('winter')).toBe(3)
+ expect(seasons.get('festival')).toBe(1)
+ })
+
+ test('specific month → season mappings', () => {
+ expect(monthSeason(1)).toBe('spring') // Thawing
+ expect(monthSeason(4)).toBe('summer') // Highsun
+ expect(monthSeason(7)).toBe('autumn') // Firstfall
+ expect(monthSeason(10)).toBe('winter') // Rainfall
+ expect(monthSeason(13)).toBe('festival')
+ })
+})
diff --git a/tests/unit/time/gameTime.test.ts b/tests/unit/time/gameTime.test.ts
new file mode 100644
index 0000000..bcbbf46
--- /dev/null
+++ b/tests/unit/time/gameTime.test.ts
@@ -0,0 +1,294 @@
+import { describe, expect, test } from 'vitest'
+import {
+ addDays,
+ addMonths,
+ addWeeks,
+ addYears,
+ compareGameTime,
+ dayOfWeek,
+ dayOfYear,
+ daysBetween,
+ formatGameTime,
+ isAfter,
+ isBefore,
+ isSameDay,
+ makeGameTime,
+ toAbsoluteDays,
+ weeksBetween
+} from 'time/gameTime'
+
+describe('makeGameTime validation', () => {
+ test('valid regular-month time', () => {
+ const t = makeGameTime(1111, 1, 14)
+ expect(t).toEqual({ year: 1111, month: 1, day: 14, tickOfDay: 0 })
+ })
+
+ test('valid festival time (day 5 is the max)', () => {
+ expect(makeGameTime(1111, 13, 5).day).toBe(5)
+ })
+
+ test('allows negative years (pre-1111 historical events)', () => {
+ expect(() => makeGameTime(-100, 5, 15)).not.toThrow()
+ })
+
+ test('rejects day 31 of a regular month', () => {
+ expect(() => makeGameTime(1111, 1, 31)).toThrow()
+ })
+
+ test('rejects day 6 of the festival', () => {
+ expect(() => makeGameTime(1111, 13, 6)).toThrow()
+ })
+
+ test('rejects day 0 and month 0', () => {
+ expect(() => makeGameTime(1111, 1, 0)).toThrow()
+ expect(() => makeGameTime(1111, 0, 1)).toThrow()
+ })
+
+ test('rejects month 14', () => {
+ expect(() => makeGameTime(1111, 14, 1)).toThrow()
+ })
+
+ test('rejects non-integer year/month/day', () => {
+ expect(() => makeGameTime(1111.5, 1, 1)).toThrow()
+ expect(() => makeGameTime(1111, 1.5, 1)).toThrow()
+ expect(() => makeGameTime(1111, 1, 1.5)).toThrow()
+ })
+
+ test('rejects negative tickOfDay', () => {
+ expect(() => makeGameTime(1111, 1, 1, -1)).toThrow()
+ })
+})
+
+describe('dayOfYear', () => {
+ test('Thawing 1 is day 1', () => {
+ expect(dayOfYear(makeGameTime(1111, 1, 1))).toBe(1)
+ })
+
+ test('Rimefrost 30 is day 360', () => {
+ expect(dayOfYear(makeGameTime(1111, 12, 30))).toBe(360)
+ })
+
+ test('Festival 1 is day 361, Festival 5 is day 365', () => {
+ expect(dayOfYear(makeGameTime(1111, 13, 1))).toBe(361)
+ expect(dayOfYear(makeGameTime(1111, 13, 5))).toBe(365)
+ })
+})
+
+describe('addDays', () => {
+ test('within a month', () => {
+ expect(addDays(makeGameTime(1111, 1, 5), 10)).toMatchObject({
+ year: 1111,
+ month: 1,
+ day: 15
+ })
+ })
+
+ test('crosses month boundary', () => {
+ expect(addDays(makeGameTime(1111, 1, 28), 5)).toMatchObject({
+ year: 1111,
+ month: 2,
+ day: 3
+ })
+ })
+
+ test('Rimefrost 30 + 1 day = Festival 1', () => {
+ expect(addDays(makeGameTime(1111, 12, 30), 1)).toMatchObject({
+ year: 1111,
+ month: 13,
+ day: 1
+ })
+ })
+
+ test('Festival 5 + 1 day = Thawing 1 next year', () => {
+ expect(addDays(makeGameTime(1111, 13, 5), 1)).toMatchObject({
+ year: 1112,
+ month: 1,
+ day: 1
+ })
+ })
+
+ test('+365 days from any date = same date next year', () => {
+ const start = makeGameTime(1111, 5, 15)
+ const end = addDays(start, 365)
+ expect(end).toMatchObject({ year: 1112, month: 5, day: 15 })
+ })
+
+ test('negative days moves backward and crosses year boundary', () => {
+ expect(addDays(makeGameTime(1112, 1, 1), -1)).toMatchObject({
+ year: 1111,
+ month: 13,
+ day: 5
+ })
+ })
+
+ test('preserves tickOfDay', () => {
+ const t = makeGameTime(1111, 1, 1, 3)
+ expect(addDays(t, 10).tickOfDay).toBe(3)
+ })
+
+ test('rejects non-integer day count', () => {
+ expect(() => addDays(makeGameTime(1111, 1, 1), 1.5)).toThrow()
+ })
+})
+
+describe('addWeeks', () => {
+ test('one week advances 7 days', () => {
+ expect(addWeeks(makeGameTime(1111, 1, 1), 1)).toMatchObject({ day: 8 })
+ })
+
+ test('52 weeks lands in the festival, not at next year', () => {
+ // 52 × 7 = 364 days; Thawing 1 + 364 = Festival 5 (day 365 - 1)
+ expect(addWeeks(makeGameTime(1111, 1, 1), 52)).toMatchObject({
+ year: 1111,
+ month: 13,
+ day: 5
+ })
+ })
+
+ test('negative weeks moves backward', () => {
+ // Greening 1 (doy 31) minus 7 days = Thawing 24 (doy 24).
+ // Months are 30 days in this calendar, not 31.
+ expect(addWeeks(makeGameTime(1111, 2, 1), -1)).toMatchObject({
+ year: 1111,
+ month: 1,
+ day: 24
+ })
+ })
+})
+
+describe('addMonths', () => {
+ test('same day, later month', () => {
+ expect(addMonths(makeGameTime(1111, 4, 15), 7)).toMatchObject({
+ year: 1111,
+ month: 11,
+ day: 15
+ })
+ })
+
+ test('day clamps when crossing into festival (only 5 days)', () => {
+ expect(addMonths(makeGameTime(1111, 12, 15), 1)).toMatchObject({
+ year: 1111,
+ month: 13,
+ day: 5
+ })
+ })
+
+ test('overflow into next year wraps through the 13-month cycle', () => {
+ // Rimefrost 15 + 2 months → Festival → Thawing next year, day preserved (15)
+ expect(addMonths(makeGameTime(1111, 12, 15), 2)).toMatchObject({
+ year: 1112,
+ month: 1,
+ day: 15
+ })
+ })
+
+ test('13 months advances exactly one year', () => {
+ expect(addMonths(makeGameTime(1111, 5, 10), 13)).toMatchObject({
+ year: 1112,
+ month: 5,
+ day: 10
+ })
+ })
+
+ test('negative months moves backward', () => {
+ expect(addMonths(makeGameTime(1112, 1, 1), -1)).toMatchObject({
+ year: 1111,
+ month: 13,
+ day: 1
+ })
+ })
+})
+
+describe('addYears', () => {
+ test('preserves month and day', () => {
+ expect(addYears(makeGameTime(1111, 5, 15), 10)).toMatchObject({
+ year: 1121,
+ month: 5,
+ day: 15
+ })
+ })
+})
+
+describe('daysBetween / weeksBetween', () => {
+ test('zero on same time', () => {
+ const t = makeGameTime(1111, 1, 1)
+ expect(daysBetween(t, t)).toBe(0)
+ })
+
+ test('positive when to is later', () => {
+ expect(daysBetween(makeGameTime(1111, 1, 1), makeGameTime(1111, 1, 11))).toBe(10)
+ })
+
+ test('negative when to is earlier', () => {
+ expect(daysBetween(makeGameTime(1111, 1, 11), makeGameTime(1111, 1, 1))).toBe(-10)
+ })
+
+ test('a full year is 365 days', () => {
+ expect(daysBetween(makeGameTime(1111, 1, 1), makeGameTime(1112, 1, 1))).toBe(365)
+ })
+
+ test('weeksBetween floors toward zero of a week count', () => {
+ expect(weeksBetween(makeGameTime(1111, 1, 1), makeGameTime(1111, 1, 8))).toBe(1)
+ expect(weeksBetween(makeGameTime(1111, 1, 1), makeGameTime(1111, 1, 7))).toBe(0)
+ })
+})
+
+describe('compareGameTime / isBefore / isAfter / isSameDay', () => {
+ const a = makeGameTime(1111, 1, 1)
+ const b = makeGameTime(1111, 1, 2)
+
+ test('ordering matches calendar', () => {
+ expect(isBefore(a, b)).toBe(true)
+ expect(isAfter(b, a)).toBe(true)
+ expect(isBefore(a, a)).toBe(false)
+ })
+
+ test('isSameDay', () => {
+ expect(isSameDay(a, a)).toBe(true)
+ expect(isSameDay(a, b)).toBe(false)
+ })
+
+ test('tickOfDay breaks ties when the calendar day is equal', () => {
+ const morning = makeGameTime(1111, 1, 1, 0)
+ const evening = makeGameTime(1111, 1, 1, 5)
+ expect(compareGameTime(morning, evening)).toBeLessThan(0)
+ expect(isSameDay(morning, evening)).toBe(true)
+ })
+})
+
+describe('dayOfWeek', () => {
+ test('advances by 1 each day, wrapping at 7', () => {
+ const start = makeGameTime(1111, 5, 1)
+ const d0 = dayOfWeek(start)
+ expect(dayOfWeek(addDays(start, 7))).toBe(d0)
+ expect(dayOfWeek(addDays(start, 14))).toBe(d0)
+ expect(dayOfWeek(addDays(start, 1))).toBe((d0 + 1) % 7)
+ })
+
+ test('every day of the week is hit within 7 consecutive days', () => {
+ const seen = new Set<number>()
+ const start = makeGameTime(1111, 5, 1)
+ for (let i = 0; i < 7; i++) {
+ seen.add(dayOfWeek(addDays(start, i)))
+ }
+ expect(seen.size).toBe(7)
+ })
+})
+
+describe('formatGameTime', () => {
+ test('renders "<day> <MonthName>, <year>"', () => {
+ expect(formatGameTime(makeGameTime(1111, 1, 14))).toBe('14 Thawing, 1111')
+ expect(formatGameTime(makeGameTime(1145, 10, 2))).toBe('2 Rainfall, 1145')
+ expect(formatGameTime(makeGameTime(1111, 13, 3))).toBe("3 Year's End Festival, 1111")
+ })
+})
+
+describe('toAbsoluteDays round-trip', () => {
+ test('monotonic with addDays', () => {
+ const start = makeGameTime(1111, 1, 1)
+ for (let i = 0; i < 1000; i++) {
+ const next = addDays(start, i)
+ expect(toAbsoluteDays(next) - toAbsoluteDays(start)).toBe(i)
+ }
+ })
+})
diff --git a/tests/unit/time/granularity.test.ts b/tests/unit/time/granularity.test.ts
new file mode 100644
index 0000000..ef22860
--- /dev/null
+++ b/tests/unit/time/granularity.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, test } from 'vitest'
+import { TICK_UNIT_BY_LIFE_STAGE, lifeStageForAge, tickUnitForAge } from 'time/granularity'
+
+describe('lifeStageForAge', () => {
+ test('stage boundaries', () => {
+ expect(lifeStageForAge(0)).toBe('infancy')
+ expect(lifeStageForAge(2)).toBe('infancy')
+ expect(lifeStageForAge(3)).toBe('early_childhood')
+ expect(lifeStageForAge(6)).toBe('early_childhood')
+ expect(lifeStageForAge(7)).toBe('middle_childhood')
+ expect(lifeStageForAge(12)).toBe('middle_childhood')
+ expect(lifeStageForAge(13)).toBe('adolescence')
+ expect(lifeStageForAge(17)).toBe('adolescence')
+ expect(lifeStageForAge(18)).toBe('young_adult')
+ expect(lifeStageForAge(29)).toBe('young_adult')
+ expect(lifeStageForAge(30)).toBe('middle_adult')
+ expect(lifeStageForAge(59)).toBe('middle_adult')
+ expect(lifeStageForAge(60)).toBe('late_adult')
+ expect(lifeStageForAge(75)).toBe('late_adult')
+ expect(lifeStageForAge(76)).toBe('elderly')
+ expect(lifeStageForAge(100)).toBe('elderly')
+ })
+
+ test('rejects negative age', () => {
+ expect(() => lifeStageForAge(-1)).toThrow()
+ })
+})
+
+describe('tickUnitForAge', () => {
+ test('infancy = year', () => {
+ expect(tickUnitForAge(1)).toBe('year')
+ })
+
+ test('childhood = season', () => {
+ expect(tickUnitForAge(5)).toBe('season')
+ expect(tickUnitForAge(10)).toBe('season')
+ })
+
+ test('adolescence = month', () => {
+ expect(tickUnitForAge(15)).toBe('month')
+ })
+
+ test('adult life = week', () => {
+ expect(tickUnitForAge(25)).toBe('week')
+ expect(tickUnitForAge(45)).toBe('week')
+ expect(tickUnitForAge(70)).toBe('week')
+ })
+
+ test('elderly = month', () => {
+ expect(tickUnitForAge(80)).toBe('month')
+ })
+})
+
+describe('TICK_UNIT_BY_LIFE_STAGE has every stage', () => {
+ test('every life stage has a tick unit', () => {
+ const stages = [
+ 'infancy',
+ 'early_childhood',
+ 'middle_childhood',
+ 'adolescence',
+ 'young_adult',
+ 'middle_adult',
+ 'late_adult',
+ 'elderly'
+ ] as const
+ for (const s of stages) {
+ expect(TICK_UNIT_BY_LIFE_STAGE[s]).toBeDefined()
+ }
+ })
+})
diff --git a/time/calendar.ts b/time/calendar.ts
new file mode 100644
index 0000000..ce61de4
--- /dev/null
+++ b/time/calendar.ts
@@ -0,0 +1,86 @@
+/**
+ * The Hollowdark calendar: 12 months × 30 days + a 5-day year-end festival,
+ * 365 days per year, 7-day weeks.
+ *
+ * Month order (per docs/01-world.md and style-bible/00-style-bible.md):
+ *
+ * spring 1 Thawing 2 Greening 3 Blossomtide
+ * summer 4 Highsun 5 Amberhaze 6 Harvestmark
+ * autumn 7 Firstfall 8 Stormturn 9 Ashfall
+ * winter 10 Rainfall 11 Hollowdark 12 Rimefrost
+ * festival 13 Year's End Festival (5 days)
+ *
+ * The year begins with Thawing (spring) and closes with the festival,
+ * which sits between Rimefrost and the next year's Thawing. This matches
+ * the style bible's cold-half / warm-half grouping and the "year-end
+ * festival" phrasing in world lore.
+ */
+
+export const DAYS_PER_MONTH = 30
+export const REGULAR_MONTH_COUNT = 12
+export const FESTIVAL_DAYS = 5
+export const FESTIVAL_MONTH = 13
+export const MONTHS_PER_YEAR = REGULAR_MONTH_COUNT + 1 // 12 regular + festival
+export const DAYS_PER_YEAR = REGULAR_MONTH_COUNT * DAYS_PER_MONTH + FESTIVAL_DAYS // 365
+export const DAYS_PER_WEEK = 7
+
+export const MONTH_NAMES = [
+ 'Thawing',
+ 'Greening',
+ 'Blossomtide',
+ 'Highsun',
+ 'Amberhaze',
+ 'Harvestmark',
+ 'Firstfall',
+ 'Stormturn',
+ 'Ashfall',
+ 'Rainfall',
+ 'Hollowdark',
+ 'Rimefrost',
+ "Year's End Festival"
+] as const
+
+export type MonthName = (typeof MONTH_NAMES)[number]
+
+export type Season = 'spring' | 'summer' | 'autumn' | 'winter' | 'festival'
+
+const SEASON_BY_MONTH: readonly Season[] = [
+ 'spring', // Thawing
+ 'spring', // Greening
+ 'spring', // Blossomtide
+ 'summer', // Highsun
+ 'summer', // Amberhaze
+ 'summer', // Harvestmark
+ 'autumn', // Firstfall
+ 'autumn', // Stormturn
+ 'autumn', // Ashfall
+ 'winter', // Rainfall
+ 'winter', // Hollowdark
+ 'winter', // Rimefrost
+ 'festival'
+]
+
+function validateMonth(monthIndex: number): void {
+ if (!Number.isInteger(monthIndex) || monthIndex < 1 || monthIndex > MONTHS_PER_YEAR) {
+ throw new Error(`Invalid month index: ${monthIndex}`)
+ }
+}
+
+export function monthName(monthIndex: number): MonthName {
+ validateMonth(monthIndex)
+ return MONTH_NAMES[monthIndex - 1] as MonthName
+}
+
+export function monthSeason(monthIndex: number): Season {
+ validateMonth(monthIndex)
+ return SEASON_BY_MONTH[monthIndex - 1] as Season
+}
+
+export function daysInMonth(monthIndex: number): number {
+ validateMonth(monthIndex)
+ return monthIndex === FESTIVAL_MONTH ? FESTIVAL_DAYS : DAYS_PER_MONTH
+}
+
+export function isFestival(monthIndex: number): boolean {
+ return monthIndex === FESTIVAL_MONTH
+}
diff --git a/time/gameTime.ts b/time/gameTime.ts
new file mode 100644
index 0000000..7e56260
--- /dev/null
+++ b/time/gameTime.ts
@@ -0,0 +1,183 @@
+import {
+ DAYS_PER_MONTH,
+ DAYS_PER_WEEK,
+ DAYS_PER_YEAR,
+ FESTIVAL_DAYS,
+ FESTIVAL_MONTH,
+ MONTHS_PER_YEAR,
+ REGULAR_MONTH_COUNT,
+ daysInMonth,
+ monthName
+} from 'time/calendar'
+
+/**
+ * A position in game time. Immutable — all arithmetic returns a new value.
+ *
+ * year integer (negative allowed for pre-1111 historical events)
+ * month 1..12 for regular months, 13 for year-end festival
+ * day 1..30 for regular months, 1..5 for festival
+ * tickOfDay 0 outside crisis mode; crisis mode subdivides the day
+ * (docs/22-crisis-mode.md)
+ */
+export interface GameTime {
+ readonly year: number
+ readonly month: number
+ readonly day: number
+ readonly tickOfDay: number
+}
+
+export function makeGameTime(
+ year: number,
+ month: number,
+ day: number,
+ tickOfDay = 0
+): GameTime {
+ if (!Number.isInteger(year)) throw new Error(`Invalid year: ${year}`)
+ if (!Number.isInteger(month) || month < 1 || month > MONTHS_PER_YEAR) {
+ throw new Error(`Invalid month: ${month}`)
+ }
+ const dim = daysInMonth(month)
+ if (!Number.isInteger(day) || day < 1 || day > dim) {
+ throw new Error(`Invalid day ${day} for month ${month} (allowed 1..${dim})`)
+ }
+ if (!Number.isInteger(tickOfDay) || tickOfDay < 0) {
+ throw new Error(`Invalid tickOfDay: ${tickOfDay}`)
+ }
+ return { year, month, day, tickOfDay }
+}
+
+/**
+ * Day of year (1-based). Thawing 1 = 1, Rimefrost 30 = 360, Festival 5 = 365.
+ */
+export function dayOfYear(time: GameTime): number {
+ if (time.month === FESTIVAL_MONTH) {
+ return REGULAR_MONTH_COUNT * DAYS_PER_MONTH + time.day
+ }
+ return (time.month - 1) * DAYS_PER_MONTH + time.day
+}
+
+function absoluteDays(time: GameTime): number {
+ return time.year * DAYS_PER_YEAR + dayOfYear(time) - 1
+}
+
+function fromAbsoluteDays(abs: number, tickOfDay: number): GameTime {
+ // Guard against non-integer arithmetic drift — time is whole days only,
+ // tickOfDay handles sub-day resolution in crisis mode.
+ if (!Number.isFinite(abs)) {
+ throw new Error(`Non-finite absolute day count: ${abs}`)
+ }
+ const year = Math.floor(abs / DAYS_PER_YEAR)
+ const rem = abs - year * DAYS_PER_YEAR // 0..364
+ const doy = rem + 1 // 1..365
+ const festivalStart = REGULAR_MONTH_COUNT * DAYS_PER_MONTH + 1 // 361
+ let month: number
+ let day: number
+ if (doy >= festivalStart) {
+ month = FESTIVAL_MONTH
+ day = doy - festivalStart + 1 // 1..5
+ } else {
+ month = Math.floor((doy - 1) / DAYS_PER_MONTH) + 1 // 1..12
+ day = ((doy - 1) % DAYS_PER_MONTH) + 1 // 1..30
+ }
+ return { year, month, day, tickOfDay }
+}
+
+export function addDays(time: GameTime, days: number): GameTime {
+ if (!Number.isInteger(days)) {
+ throw new Error(`addDays requires an integer (got ${days})`)
+ }
+ return fromAbsoluteDays(absoluteDays(time) + days, time.tickOfDay)
+}
+
+export function addWeeks(time: GameTime, weeks: number): GameTime {
+ if (!Number.isInteger(weeks)) {
+ throw new Error(`addWeeks requires an integer (got ${weeks})`)
+ }
+ return addDays(time, weeks * DAYS_PER_WEEK)
+}
+
+/**
+ * Advance by whole months through the 13-month cycle. Day is clamped to
+ * the target month's length — landing on a festival day from a month with
+ * day > 5 produces festival day 5.
+ */
+export function addMonths(time: GameTime, months: number): GameTime {
+ if (!Number.isInteger(months)) {
+ throw new Error(`addMonths requires an integer (got ${months})`)
+ }
+ // Zero-based cycle arithmetic: months are 1..13, so we work in 0..12.
+ const totalCycles = (time.month - 1) + months
+ const yearDelta = Math.floor(totalCycles / MONTHS_PER_YEAR)
+ const monthIndex = ((totalCycles % MONTHS_PER_YEAR) + MONTHS_PER_YEAR) % MONTHS_PER_YEAR
+ const month = monthIndex + 1
+ const dim = daysInMonth(month)
+ const day = Math.min(time.day, dim)
+ return { year: time.year + yearDelta, month, day, tickOfDay: time.tickOfDay }
+}
+
+export function addYears(time: GameTime, years: number): GameTime {
+ if (!Number.isInteger(years)) {
+ throw new Error(`addYears requires an integer (got ${years})`)
+ }
+ return { ...time, year: time.year + years }
+}
+
+export function daysBetween(from: GameTime, to: GameTime): number {
+ return absoluteDays(to) - absoluteDays(from)
+}
+
+export function weeksBetween(from: GameTime, to: GameTime): number {
+ return Math.floor(daysBetween(from, to) / DAYS_PER_WEEK)
+}
+
+export function compareGameTime(a: GameTime, b: GameTime): number {
+ const diff = absoluteDays(a) - absoluteDays(b)
+ if (diff !== 0) return diff
+ return a.tickOfDay - b.tickOfDay
+}
+
+export function isBefore(a: GameTime, b: GameTime): boolean {
+ return compareGameTime(a, b) < 0
+}
+
+export function isAfter(a: GameTime, b: GameTime): boolean {
+ return compareGameTime(a, b) > 0
+}
+
+export function isSameDay(a: GameTime, b: GameTime): boolean {
+ return a.year === b.year && a.month === b.month && a.day === b.day
+}
+
+/**
+ * Day of week as an integer 0..6. Stable: same GameTime always maps to the
+ * same index. Anchor: year 0, Thawing 1 (the calendar's epoch) is index 0.
+ */
+export function dayOfWeek(time: GameTime): number {
+ const abs = absoluteDays(time)
+ return ((abs % DAYS_PER_WEEK) + DAYS_PER_WEEK) % DAYS_PER_WEEK
+}
+
+/**
+ * Human-facing label for a GameTime. Does not attempt to express tickOfDay.
+ */
+export function formatGameTime(time: GameTime): string {
+ return `${time.day} ${monthName(time.month)}, ${time.year}`
+}
+
+/**
+ * Exported so consumers can re-key: e.g., Set<AbsoluteDay> for dedup.
+ */
+export function toAbsoluteDays(time: GameTime): number {
+ return absoluteDays(time)
+}
+
+// Re-exports for convenience at the 'time' import.
+export {
+ DAYS_PER_MONTH,
+ DAYS_PER_WEEK,
+ DAYS_PER_YEAR,
+ FESTIVAL_DAYS,
+ FESTIVAL_MONTH,
+ MONTHS_PER_YEAR,
+ REGULAR_MONTH_COUNT
+}
diff --git a/time/granularity.ts b/time/granularity.ts
new file mode 100644
index 0000000..0643c47
--- /dev/null
+++ b/time/granularity.ts
@@ -0,0 +1,47 @@
+/**
+ * Tick granularity by life stage.
+ *
+ * One tick represents a different span of time depending on the character's
+ * age — infancy advances in years because there isn't weekly texture worth
+ * resolving, adulthood in weeks because that's the rhythm the design lives
+ * at. See docs/05-time-system.md and ARCHITECTURE.md §5.
+ */
+
+export type LifeStage =
+ | 'infancy'
+ | 'early_childhood'
+ | 'middle_childhood'
+ | 'adolescence'
+ | 'young_adult'
+ | 'middle_adult'
+ | 'late_adult'
+ | 'elderly'
+
+export type TickUnit = 'year' | 'season' | 'month' | 'week'
+
+export const TICK_UNIT_BY_LIFE_STAGE: Readonly<Record<LifeStage, TickUnit>> = {
+ infancy: 'year',
+ early_childhood: 'season',
+ middle_childhood: 'season',
+ adolescence: 'month',
+ young_adult: 'week',
+ middle_adult: 'week',
+ late_adult: 'week',
+ elderly: 'month'
+}
+
+export function lifeStageForAge(ageYears: number): LifeStage {
+ if (ageYears < 0) throw new Error(`Invalid age: ${ageYears}`)
+ if (ageYears < 3) return 'infancy'
+ if (ageYears < 7) return 'early_childhood'
+ if (ageYears < 13) return 'middle_childhood'
+ if (ageYears < 18) return 'adolescence'
+ if (ageYears < 30) return 'young_adult'
+ if (ageYears < 60) return 'middle_adult'
+ if (ageYears < 76) return 'late_adult'
+ return 'elderly'
+}
+
+export function tickUnitForAge(ageYears: number): TickUnit {
+ return TICK_UNIT_BY_LIFE_STAGE[lifeStageForAge(ageYears)]
+}
diff --git a/time/index.ts b/time/index.ts
new file mode 100644
index 0000000..34a518d
--- /dev/null
+++ b/time/index.ts
@@ -0,0 +1,45 @@
+export {
+ DAYS_PER_MONTH,
+ DAYS_PER_WEEK,
+ DAYS_PER_YEAR,
+ FESTIVAL_DAYS,
+ FESTIVAL_MONTH,
+ MONTHS_PER_YEAR,
+ MONTH_NAMES,
+ REGULAR_MONTH_COUNT,
+ daysInMonth,
+ isFestival,
+ monthName,
+ monthSeason,
+ type MonthName,
+ type Season
+} from 'time/calendar'
+
+export {
+ addDays,
+ addMonths,
+ addWeeks,
+ addYears,
+ compareGameTime,
+ dayOfWeek,
+ dayOfYear,
+ daysBetween,
+ formatGameTime,
+ isAfter,
+ isBefore,
+ isSameDay,
+ makeGameTime,
+ toAbsoluteDays,
+ weeksBetween,
+ type GameTime
+} from 'time/gameTime'
+
+export {
+ TICK_UNIT_BY_LIFE_STAGE,
+ lifeStageForAge,
+ tickUnitForAge,
+ type LifeStage,
+ type TickUnit
+} from 'time/granularity'
+
+export { SPEEDS, type Speed } from 'time/speed'
diff --git a/time/speed.ts b/time/speed.ts
new file mode 100644
index 0000000..9a9bb11
--- /dev/null
+++ b/time/speed.ts
@@ -0,0 +1,11 @@
+/**
+ * Player-controlled simulation speed. Only three states ever exist
+ * (docs/05-time-system.md): time is stopped, running at reading pace,
+ * or running fast with compressed flow.
+ *
+ * Scenes auto-set the effective speed to 'paused'; the intended speed is
+ * preserved so the simulation returns to it when the scene resolves.
+ */
+export type Speed = 'paused' | 'play' | 'fast'
+
+export const SPEEDS: readonly Speed[] = ['paused', 'play', 'fast']