aboutsummaryrefslogtreecommitdiff
path: root/tests
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 /tests
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.
Diffstat (limited to 'tests')
-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
3 files changed, 445 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()
+ }
+ })
+})