diff --git a/api/src/lib/astronomyConstellationStargazing.ts b/api/src/lib/astronomyConstellationStargazing.ts new file mode 100644 index 00000000..b294c0b9 --- /dev/null +++ b/api/src/lib/astronomyConstellationStargazing.ts @@ -0,0 +1,141 @@ +import crypto from "node:crypto"; + +/** + * Astral Stargazing, Zodiac Constellation Alignment & Astrological Blessing Engine for OpenAO MMORPG. + * Simulates telescope sky quadrant mapping, star charting, lunar resonance multipliers (New, Crescent, Half, Full Moon), + * and channeling celestial blessings. + */ + +export type SkyQuadrant = "NORTH" | "SOUTH" | "EAST" | "WEST"; +export type ZodiacConstellation = "THE_DRAGON" | "THE_PHOENIX" | "THE_TITAN" | "THE_VOID_WEAVER"; +export type LunarPhase = "NEW_MOON" | "CRESCENT_MOON" | "HALF_MOON" | "FULL_MOON"; + +export interface ConstellationData { + name: ZodiacConstellation; + quadrant: SkyQuadrant; + requiredStars: number; + baseBlessingBonus: number; +} + +export interface TelescopeObservationSession { + sessionId: string; + playerId: string; + targetConstellation: ZodiacConstellation; + starsMappedCount: number; + requiredStarsCount: number; + isFullyCharted: boolean; +} + +export interface ActiveAstrologicalBlessing { + blessingId: string; + playerId: string; + constellation: ZodiacConstellation; + lunarPhase: LunarPhase; + bonusStatValue: number; + durationSeconds: number; + expiresAtEpochMs: number; +} + +export const CONSTELLATION_CATALOG: Record = { + THE_DRAGON: { name: "THE_DRAGON", quadrant: "NORTH", requiredStars: 5, baseBlessingBonus: 50 }, + THE_PHOENIX: { name: "THE_PHOENIX", quadrant: "SOUTH", requiredStars: 4, baseBlessingBonus: 40 }, + THE_TITAN: { name: "THE_TITAN", quadrant: "EAST", requiredStars: 6, baseBlessingBonus: 60 }, + THE_VOID_WEAVER: { name: "THE_VOID_WEAVER", quadrant: "WEST", requiredStars: 5, baseBlessingBonus: 55 }, +}; + +export const LUNAR_PHASE_MULTIPLIERS: Record = { + NEW_MOON: 1.0, + CRESCENT_MOON: 1.25, + HALF_MOON: 1.5, + FULL_MOON: 2.0, +}; + +export class AstronomyConstellationStargazingEngine { + /** + * Starts a telescope observation session targeting a constellation with collision-resistant UUID. + */ + public static startObservationSession( + playerId: string, + constellation: ZodiacConstellation, + currentEpochMs = Date.now() + ): TelescopeObservationSession { + const data = CONSTELLATION_CATALOG[constellation]; + if (!data) { + throw new Error(`Unsupported constellation: ${String(constellation)}`); + } + + const uuid = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${currentEpochMs}_${Math.random()}`; + + return { + sessionId: `obs_${constellation.toLowerCase()}_${uuid}`, + playerId, + targetConstellation: constellation, + starsMappedCount: 0, + requiredStarsCount: data.requiredStars, + isFullyCharted: false, + }; + } + + /** + * Maps a star into the active constellation chart. + */ + public static mapStar( + session: TelescopeObservationSession, + quadrantAligned: SkyQuadrant + ): { success: boolean; starsMapped: number; isCompleted: boolean; reason?: string } { + if (!session || session.isFullyCharted) { + return { success: false, starsMapped: session?.starsMappedCount ?? 0, isCompleted: session?.isFullyCharted ?? false, reason: "Observation session already completed." }; + } + + const data = CONSTELLATION_CATALOG[session.targetConstellation]; + if (quadrantAligned !== data.quadrant) { + return { success: false, starsMapped: session.starsMappedCount, isCompleted: false, reason: `Telescope aimed at ${quadrantAligned}, but ${data.name} is located in the ${data.quadrant} sky.` }; + } + + session.starsMappedCount += 1; + if (session.starsMappedCount >= session.requiredStarsCount) { + session.isFullyCharted = true; + } + + return { + success: true, + starsMapped: session.starsMappedCount, + isCompleted: session.isFullyCharted, + }; + } + + /** + * Channels a celestial blessing from a completed constellation chart under current lunar phase with UUID identifier. + */ + public static channelCelestialBlessing( + session: TelescopeObservationSession, + lunarPhase: LunarPhase, + durationMinutes = 15, + currentEpochMs = Date.now() + ): { success: boolean; blessing?: ActiveAstrologicalBlessing; reason?: string } { + if (!session || !session.isFullyCharted) { + return { success: false, reason: "Cannot channel blessing from an incomplete constellation chart." }; + } + + const data = CONSTELLATION_CATALOG[session.targetConstellation]; + const lunarMult = LUNAR_PHASE_MULTIPLIERS[lunarPhase] ?? 1.0; + const totalStatBonus = Math.round(data.baseBlessingBonus * lunarMult); + const dur = Number.isFinite(durationMinutes) ? Math.max(1, durationMinutes) : 15; + const uuid = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${currentEpochMs}_${Math.random()}`; + + const blessing: ActiveAstrologicalBlessing = { + blessingId: `bless_${session.targetConstellation.toLowerCase()}_${uuid}`, + playerId: session.playerId, + constellation: session.targetConstellation, + lunarPhase, + bonusStatValue: totalStatBonus, + durationSeconds: dur * 60, + expiresAtEpochMs: currentEpochMs + dur * 60 * 1000, + }; + + return { + success: true, + blessing, + }; + } +} \ No newline at end of file diff --git a/api/src/tests/astronomyConstellationStargazing.test.ts b/api/src/tests/astronomyConstellationStargazing.test.ts new file mode 100644 index 00000000..d23d4371 --- /dev/null +++ b/api/src/tests/astronomyConstellationStargazing.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { + AstronomyConstellationStargazingEngine, + TelescopeObservationSession, +} from "../lib/astronomyConstellationStargazing.js"; + +describe("AstronomyConstellationStargazingEngine Charting & Lunar Blessings", () => { + it("starts stargazing session and successfully charts THE_DRAGON in the NORTH quadrant", () => { + const session = AstronomyConstellationStargazingEngine.startObservationSession("astronomer_1", "THE_DRAGON", 100000); + expect(session.starsMappedCount).toBe(0); + expect(session.requiredStarsCount).toBe(5); + + for (let i = 0; i < 4; i++) { + const mapRes = AstronomyConstellationStargazingEngine.mapStar(session, "NORTH"); + expect(mapRes.success).toBe(true); + } + + const finalStar = AstronomyConstellationStargazingEngine.mapStar(session, "NORTH"); + expect(finalStar.success).toBe(true); + expect(finalStar.isCompleted).toBe(true); + expect(session.isFullyCharted).toBe(true); + }); + + it("rejects star mapping when telescope is aimed at the wrong quadrant", () => { + const session = AstronomyConstellationStargazingEngine.startObservationSession("astronomer_1", "THE_PHOENIX", 100000); + + // Phoenix is in SOUTH, aimed at NORTH + const misaligned = AstronomyConstellationStargazingEngine.mapStar(session, "NORTH"); + expect(misaligned.success).toBe(false); + expect(misaligned.reason).toContain("aimed at NORTH, but THE_PHOENIX is located in the SOUTH"); + }); + + it("channels celestial blessing under FULL_MOON applying 2.0x lunar multiplier", () => { + const session = AstronomyConstellationStargazingEngine.startObservationSession("astronomer_1", "THE_TITAN", 100000); + session.starsMappedCount = 6; + session.isFullyCharted = true; + + // Titan base 60 bonus * 2.0 Full Moon = 120 stat bonus + const blessRes = AstronomyConstellationStargazingEngine.channelCelestialBlessing(session, "FULL_MOON", 20, 100000); + expect(blessRes.success).toBe(true); + expect(blessRes.blessing?.bonusStatValue).toBe(120); + expect(blessRes.blessing?.durationSeconds).toBe(1200); // 20 mins * 60s + }); + + it("rejects blessing channel when chart is incomplete", () => { + const incompleteSession = AstronomyConstellationStargazingEngine.startObservationSession("p", "THE_VOID_WEAVER", 100000); + const res = AstronomyConstellationStargazingEngine.channelCelestialBlessing(incompleteSession, "NEW_MOON"); + + expect(res.success).toBe(false); + expect(res.reason).toContain("incomplete constellation chart"); + }); + + it("guards against unsupported constellation types", () => { + expect(() => AstronomyConstellationStargazingEngine.startObservationSession("p", "THE_UNICORN" as any)).toThrow( + "Unsupported constellation" + ); + }); +}); \ No newline at end of file