feat(tarot): 카드 78장 메타데이터 (메이저 22 + 마이너 56) (T8)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 00:30:41 +09:00
parent 2042457000
commit cdf8759aef
2 changed files with 252 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
const cardImage = (slug) => `/images/tarot/cards/${slug}.png`;
const MAJOR_ARCANA = [
{ id: 0, slug: 'the-fool', name: '바보', nameEn: 'The Fool', element: 'air',
keywords: ['새로운 시작','도약','순수','자유','무한한 가능성'],
reversedKeywords: ['무모함','경솔함','위험','방향 상실','준비 부족'],
meaningUpright: '미지의 세계로 내딛는 첫걸음. 계산보다 직관과 신뢰로 시작하는 시기. 위험은 있으나 그 자체가 성장의 통로.',
meaningReversed: '준비 없이 뛰어들어 위험을 자초하거나, 두려움으로 첫걸음을 미루는 상태.' },
{ id: 1, slug: 'the-magician', name: '마법사', nameEn: 'The Magician', element: 'air',
keywords: ['의지','창조','집중','실행력','자기 효능감'],
reversedKeywords: ['조작','자기 기만','산만함','잠재력 미발현'],
meaningUpright: '내가 가진 자원과 의지를 명확히 모아 현실로 옮길 수 있는 시기. 시작과 추진력이 일치한다.',
meaningReversed: '의도가 흐려지거나 능력을 잘못 사용해 자기 기만에 빠질 위험.' },
{ id: 2, slug: 'the-high-priestess', name: '여사제', nameEn: 'The High Priestess', element: 'water',
keywords: ['직관','내면의 지혜','비밀','잠재의식','신비'],
reversedKeywords: ['직관 무시','정보 단절','억압','표면적 판단'],
meaningUpright: '드러나지 않은 진실을 들여다볼 시기. 외부 답이 아닌 내면의 신호에 귀 기울일 때 길이 보인다.',
meaningReversed: '직관을 무시하거나 비밀이 노출되어 균형이 깨지는 상태.' },
{ id: 3, slug: 'the-empress', name: '여황제', nameEn: 'The Empress', element: 'earth',
keywords: ['풍요','창조성','어머니','자연','감각적 충만'],
reversedKeywords: ['창조 정체','과보호','의존','정서적 소진'],
meaningUpright: '풍요와 창조가 무르익는 시기. 보살핌·예술·자연과의 연결에서 에너지가 자라남.',
meaningReversed: '돌봄이 과해 자신을 잃거나, 창조 흐름이 정체된 상태.' },
{ id: 4, slug: 'the-emperor', name: '황제', nameEn: 'The Emperor', element: 'fire',
keywords: ['권위','구조','책임','통제','아버지'],
reversedKeywords: ['독선','경직','통제 욕구','권위 남용'],
meaningUpright: '질서와 책임을 세워 안정을 만드는 시기. 명확한 경계와 원칙이 힘이 된다.',
meaningReversed: '경직된 통제가 관계를 막거나, 권위 남용으로 신뢰가 깨질 위험.' },
{ id: 5, slug: 'the-hierophant', name: '교황', nameEn: 'The Hierophant', element: 'earth',
keywords: ['전통','가르침','믿음','제도','조언자'],
reversedKeywords: ['관습 거부','독학','권위 도전','형식주의'],
meaningUpright: '전통과 멘토의 지혜를 빌릴 때. 검증된 길과 가르침이 도움을 준다.',
meaningReversed: '관습이 답이 되지 않거나, 자기만의 길을 새로 찾고 싶은 시기.' },
{ id: 6, slug: 'the-lovers', name: '연인', nameEn: 'The Lovers', element: 'air',
keywords: ['사랑','선택','조화','가치관 일치','결합'],
reversedKeywords: ['관계 갈등','선택의 어려움','가치관 충돌','미성숙한 결정'],
meaningUpright: '깊은 결합과 가치관의 일치. 중요한 선택을 마음으로부터 내릴 때.',
meaningReversed: '두 길 사이에서 머뭇거리거나, 이미 내린 선택의 의구심이 커지는 시기.' },
{ id: 7, slug: 'the-chariot', name: '전차', nameEn: 'The Chariot', element: 'water',
keywords: ['의지','전진','승리','자기 통제','목표 추진'],
reversedKeywords: ['방향 상실','자기 통제 부족','과욕','지연'],
meaningUpright: '명확한 목표와 강한 의지로 전진하는 시기. 상반된 힘들을 조율해 추진력으로 바꾼다.',
meaningReversed: '방향이 흔들리거나 통제력을 잃어 진전이 멈춘 상태.' },
{ id: 8, slug: 'strength', name: '힘', nameEn: 'Strength', element: 'fire',
keywords: ['내면의 힘','용기','부드러운 통제','인내','자제'],
reversedKeywords: ['자신감 부족','감정 과잉','자제력 상실'],
meaningUpright: '강제가 아닌 부드러움으로 어려움을 다루는 시기. 진짜 힘은 자기 통제와 인내에서 나온다.',
meaningReversed: '감정에 휘말려 자제력을 잃거나, 자신감이 흔들리는 상태.' },
{ id: 9, slug: 'the-hermit', name: '은둔자', nameEn: 'The Hermit', element: 'earth',
keywords: ['성찰','고독','내면의 빛','지혜 추구','은둔'],
reversedKeywords: ['고립','회피','외로움','자기 폐쇄'],
meaningUpright: '바깥 소음에서 물러나 자기 안의 빛으로 길을 찾는 시기.',
meaningReversed: '회피·고립이 길어져 균형이 깨진 상태.' },
{ id: 10, slug: 'wheel-of-fortune', name: '운명의 수레바퀴', nameEn: 'Wheel of Fortune', element: 'fire',
keywords: ['전환점','순환','운명','기회','변화'],
reversedKeywords: ['악순환','정체','불운','통제력 상실'],
meaningUpright: '큰 흐름이 바뀌는 전환점. 받아들이고 흐름에 올라타면 새로운 국면이 열린다.',
meaningReversed: '순환의 하강기. 흐름을 거스르기보다 자세를 낮추고 견뎌야 할 시기.' },
{ id: 11, slug: 'justice', name: '정의', nameEn: 'Justice', element: 'air',
keywords: ['정의','균형','진실','책임','명료성'],
reversedKeywords: ['불공정','책임 회피','판단 왜곡'],
meaningUpright: '원인과 결과가 명확히 드러나는 시기. 진실에 기초한 결정이 길을 연다.',
meaningReversed: '책임을 외면하거나 한쪽 시각에 치우쳐 균형이 깨진 상태.' },
{ id: 12, slug: 'the-hanged-man', name: '매달린 사람', nameEn: 'The Hanged Man', element: 'water',
keywords: ['시야 전환','내려놓음','희생','수용','새로운 관점'],
reversedKeywords: ['고집','정체','희생 거부','시야의 닫힘'],
meaningUpright: '잠시 멈춰 시야를 뒤집어 보는 시기. 강제로 풀려 하지 말고 다른 각도를 받아들이자.',
meaningReversed: '내려놓아야 할 것을 붙들고 있어 정체가 길어지는 상태.' },
{ id: 13, slug: 'death', name: '죽음', nameEn: 'Death', element: 'water',
keywords: ['종결','변형','놓아주기','재탄생','전환'],
reversedKeywords: ['변화 저항','놓지 못함','정체','두려움'],
meaningUpright: '한 챕터가 닫히고 새로운 챕터가 열리는 결정적 전환. 끝맺음이 새 시작의 조건이다.',
meaningReversed: '끝나야 할 것을 붙들어 변화가 늦어지는 상태.' },
{ id: 14, slug: 'temperance', name: '절제', nameEn: 'Temperance', element: 'fire',
keywords: ['조화','중용','연금술적 결합','인내','치유'],
reversedKeywords: ['불균형','과잉','조급함','조화 상실'],
meaningUpright: '서로 다른 것들을 천천히 섞어 균형 잡힌 상태로 만드는 시기. 조급함보다 끈기.',
meaningReversed: '극단으로 치우치거나 조급함이 흐름을 깨는 상태.' },
{ id: 15, slug: 'the-devil', name: '악마', nameEn: 'The Devil', element: 'earth',
keywords: ['속박','집착','중독','물질주의','그림자'],
reversedKeywords: ['해방','구속에서 벗어남','자각','단절'],
meaningUpright: '스스로 묶어둔 사슬을 직시할 시기. 욕망·중독·집착이 시야를 가린다.',
meaningReversed: '구속이 풀리며 의식적인 해방이 가능한 상태.' },
{ id: 16, slug: 'the-tower', name: '탑', nameEn: 'The Tower', element: 'fire',
keywords: ['붕괴','갑작스러운 변화','각성','진실 노출'],
reversedKeywords: ['붕괴 회피','두려움','지연된 충격'],
meaningUpright: '거짓 기반 위의 구조가 갑자기 무너지는 시기. 충격은 크나 진실의 자리를 만든다.',
meaningReversed: '붕괴를 미루거나 외면해 더 큰 충격을 키울 수 있는 상태.' },
{ id: 17, slug: 'the-star', name: '별', nameEn: 'The Star', element: 'air',
keywords: ['희망','영감','치유','평온','신뢰'],
reversedKeywords: ['희망 상실','자기 의심','단절감'],
meaningUpright: '폭풍 뒤의 평온. 영감이 회복되고 길게 볼 힘이 돌아오는 시기.',
meaningReversed: '의심과 무력감으로 빛이 잘 보이지 않는 상태.' },
{ id: 18, slug: 'the-moon', name: '달', nameEn: 'The Moon', element: 'water',
keywords: ['직관','무의식','환영','불안','꿈'],
reversedKeywords: ['혼란 해소','진실 드러남','직관 회복'],
meaningUpright: '명확하지 않은 신호와 감정의 파도. 직관을 따르되 환상은 분별해야 하는 시기.',
meaningReversed: '안개가 걷히며 가려졌던 진실이 드러나는 상태.' },
{ id: 19, slug: 'the-sun', name: '태양', nameEn: 'The Sun', element: 'fire',
keywords: ['기쁨','성공','명료성','활력','진실'],
reversedKeywords: ['과신','단편적 기쁨','피상적 성공'],
meaningUpright: '명료하고 따뜻한 시기. 노력의 결실이 분명히 드러난다.',
meaningReversed: '겉만 환한 기쁨이거나, 자만으로 본질을 놓칠 위험.' },
{ id: 20, slug: 'judgement', name: '심판', nameEn: 'Judgement', element: 'fire',
keywords: ['각성','부름','재평가','부활','결단'],
reversedKeywords: ['자기 비판','부름 무시','과거에 묶임'],
meaningUpright: '오랜 흐름을 정산하고 새 부름에 응답하는 시기. 결단의 순간.',
meaningReversed: '과거의 비판이나 미련에 묶여 새 길로 나서지 못하는 상태.' },
{ id: 21, slug: 'the-world', name: '세계', nameEn: 'The World', element: 'earth',
keywords: ['완성','통합','성취','순환의 닫힘','전체성'],
reversedKeywords: ['미완성','마무리 지연','반복'],
meaningUpright: '한 사이클의 완성과 통합. 다음 시작을 위한 단단한 기반이 마련된다.',
meaningReversed: '마무리가 늦어지거나 반복으로 인해 다음 단계로 나아가지 못하는 상태.' },
];
const SUITS = [
{ suit: 'wands', element: 'fire', kr: '완드' },
{ suit: 'cups', element: 'water', kr: '컵' },
{ suit: 'swords', element: 'air', kr: '소드' },
{ suit: 'pentacles', element: 'earth', kr: '펜타클' },
];
const RANK_NAMES = ['에이스', '2', '3', '4', '5', '6', '7', '8', '9', '10', '시종', '기사', '여왕', '왕'];
const RANK_EN = ['Ace', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Page', 'Knight', 'Queen', 'King'];
const SUIT_NAMES_EN = { wands: 'Wands', cups: 'Cups', swords: 'Swords', pentacles: 'Pentacles' };
const SUIT_KEYWORDS = {
wands: { up: ['열정','창조','행동','의지'], down: ['고갈','지연','분열'], theme: '의지와 창조의 불꽃' },
cups: { up: ['감정','관계','직관','사랑'], down: ['감정 정체','상실','오해'], theme: '감정과 관계의 흐름' },
swords: { up: ['사고','갈등','명료성','진실'], down: ['혼란','과도한 사고','오해'], theme: '사고와 결단의 칼날' },
pentacles: { up: ['물질','일','안정','성취'], down: ['결핍','정체','집착'], theme: '물질과 일의 토대' },
};
function buildMinor() {
const out = [];
let id = 22;
for (const { suit, element, kr } of SUITS) {
for (let rank = 1; rank <= 14; rank++) {
const krName = `${kr} ${RANK_NAMES[rank - 1]}`;
const enName = `${RANK_EN[rank - 1]} of ${SUIT_NAMES_EN[suit]}`;
const kw = SUIT_KEYWORDS[suit];
out.push({
id: id++,
slug: `${RANK_EN[rank - 1].toLowerCase()}-of-${suit}`,
name: krName,
nameEn: enName,
arcana: 'minor',
suit,
rank,
element,
keywords: [...kw.up, `${kr} ${rank}의 단계`],
reversedKeywords: [...kw.down, `${kr} ${rank} 정체`],
meaningUpright: `${kw.theme}${krName} 단계. ${kw.up.join(', ')} 의 흐름이 작동하는 시점.`,
meaningReversed: `${kr} 흐름의 정체 또는 왜곡. ${kw.down.join(', ')} 양상이 드러남.`,
});
}
}
return out;
}
export const TAROT_DECK = [
...MAJOR_ARCANA.map((c) => ({
...c,
arcana: 'major',
image: cardImage(c.slug),
})),
...buildMinor().map((c) => ({ ...c, image: cardImage(c.slug) })),
];
export const SPREADS = {
one_card: {
id: 'one_card',
name: '오늘의 카드',
positions: [{ idx: 0, label: '오늘' }],
},
three_card: {
id: 'three_card',
name: '3장 스프레드',
positions: [
{ idx: 0, label: '과거' },
{ idx: 1, label: '현재' },
{ idx: 2, label: '미래' },
],
},
};
export const CATEGORIES = ['연애', '일·커리어', '관계', '재물', '건강', '일반'];
export function findCard(slug) {
return TAROT_DECK.find((c) => c.slug === slug) || null;
}

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { TAROT_DECK, SPREADS, CATEGORIES } from './cards';
describe('TAROT_DECK', () => {
it('총 78장', () => {
expect(TAROT_DECK).toHaveLength(78);
});
it('메이저 22장 + 마이너 56장', () => {
const majors = TAROT_DECK.filter((c) => c.arcana === 'major');
const minors = TAROT_DECK.filter((c) => c.arcana === 'minor');
expect(majors).toHaveLength(22);
expect(minors).toHaveLength(56);
});
it('slug 중복 없음', () => {
const slugs = TAROT_DECK.map((c) => c.slug);
expect(new Set(slugs).size).toBe(slugs.length);
});
it('모든 카드에 필수 필드 존재', () => {
for (const c of TAROT_DECK) {
expect(c.id).toBeTypeOf('number');
expect(c.slug).toBeTruthy();
expect(c.name).toBeTruthy();
expect(c.arcana).toMatch(/^(major|minor)$/);
expect(c.keywords).toBeInstanceOf(Array);
expect(c.keywords.length).toBeGreaterThan(0);
expect(c.reversedKeywords).toBeInstanceOf(Array);
expect(c.reversedKeywords.length).toBeGreaterThan(0);
expect(c.meaningUpright).toBeTruthy();
expect(c.meaningReversed).toBeTruthy();
}
});
it('마이너 카드는 suit, rank 필드를 가진다', () => {
const minors = TAROT_DECK.filter((c) => c.arcana === 'minor');
for (const c of minors) {
expect(c.suit).toMatch(/^(wands|cups|swords|pentacles)$/);
expect(c.rank).toBeTypeOf('number');
expect(c.rank).toBeGreaterThanOrEqual(1);
expect(c.rank).toBeLessThanOrEqual(14);
}
});
});
describe('SPREADS', () => {
it('one_card / three_card 정의 존재', () => {
expect(SPREADS.one_card.positions).toHaveLength(1);
expect(SPREADS.three_card.positions).toHaveLength(3);
expect(SPREADS.three_card.positions.map((p) => p.label)).toEqual(['과거', '현재', '미래']);
});
});
describe('CATEGORIES', () => {
it('6개 카테고리', () => {
expect(CATEGORIES).toHaveLength(6);
expect(CATEGORIES).toContain('연애');
expect(CATEGORIES).toContain('일·커리어');
});
});