diff options
| author | Bobby <[email protected]> | 2026-04-22 06:48:46 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-04-22 06:48:46 +0530 |
| commit | 4383213a23e27903e1fed270f1dbcc116644c7fc (patch) | |
| tree | c5920b6ca2d7faf7f41e29694c7a54956f213c07 /tests/unit | |
| parent | 47381ca2cd6dec22848b66924d9558a191e47218 (diff) | |
| download | hollowdark-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/unit')
| -rw-r--r-- | tests/unit/time/calendar.test.ts | 81 | ||||
| -rw-r--r-- | tests/unit/time/gameTime.test.ts | 294 | ||||
| -rw-r--r-- | tests/unit/time/granularity.test.ts | 70 |
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() + } + }) +}) |
