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 | |
| 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.
| -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 | ||||
| -rw-r--r-- | time/calendar.ts | 86 | ||||
| -rw-r--r-- | time/gameTime.ts | 183 | ||||
| -rw-r--r-- | time/granularity.ts | 47 | ||||
| -rw-r--r-- | time/index.ts | 45 | ||||
| -rw-r--r-- | time/speed.ts | 11 |
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'] |
