// Package imagecard implements 意象卡片 draws (projection / reflection, not tarot UI). package imagecard import ( "fmt" "hash/fnv" "time" ) // Card is one exploration card. type Card struct { ID string `json:"id"` Title string `json:"title"` Imagery string `json:"imagery"` Prompt string `json:"prompt"` Tip string `json:"tip"` } // DrawResult is API payload for a draw. type DrawResult struct { Scene string `json:"scene"` Cards []Card `json:"cards"` Summary map[string]any `json:"summary"` Detail map[string]any `json:"detail"` QuotaLeft int `json:"quota_left"` } var scenes = []string{ "情绪整理", "关系", "选择", "自我", "工作节奏", "边界", "休息", "表达", } // Scenes returns available reflection scenes. func Scenes() []map[string]string { out := make([]map[string]string, 0, len(scenes)) for _, s := range scenes { out = append(out, map[string]string{"key": s, "label": s}) } return out } // deck built once: 12 handcrafted + generated to ≥78. var deck = buildDeck() func buildDeck() []Card { base := []Card{ {"c01", "微光小路", "一条只够一人走过的小径,尽头有一点光。", "此刻你最想靠近的「光」是什么?", "选一个最小行动靠近它,不必一次走完。"}, {"c02", "安静的杯子", "桌上杯子里的水是温的,蒸汽慢慢散开。", "你最近在为谁/什么「保温」?有没有过热?", "今天给自己倒一杯水,只为自己停两分钟。"}, {"c03", "未拆的信", "信封边缘有点磨损,还没打开。", "有哪句话你一直想说却还没说?", "写下来即可,不一定立刻发出。"}, {"c04", "桥与河", "桥稳稳跨过河,水流在下面经过。", "你现在更需要「过去」还是「停在岸上感受」?", "允许自己选一边,并告诉相关的人。"}, {"c05", "收拾桌面", "杂物被归类,只留一件重要的东西在中间。", "若只能保留一件「最重要」,会是什么?", "本周减少一件消耗你的杂事。"}, {"c06", "窗边呼吸", "窗外有风,窗帘轻轻动。", "你的呼吸是浅还是深?身体哪里最紧?", "做 4 次慢呼吸,肩再放松一点。"}, {"c07", "同行的影子", "两个人影并排,步伐不完全一致。", "关系里你希望对方怎样配合你的节奏?", "用一句具体请求代替抱怨。"}, {"c08", "种子与土", "土里有一颗刚发芽的种子。", "你正在酝酿、还没被人看见的是什么?", "给它一点时间,并做一个保护边界。"}, {"c09", "地图折痕", "地图被折过很多次,路线仍可辨认。", "过去哪些弯路其实教了你方法?", "写下一条「下次可以更早用的经验」。"}, {"c10", "钥匙串", "一串钥匙里有一把还没用过。", "你手头有哪项资源/能力还没启用?", "本周试用一次那个「备用钥匙」。"}, {"c11", "退潮沙滩", "潮水退去,露出一些小贝壳。", "热闹过后,你发现了什么真实感受?", "把感受写三句,不评判对错。"}, {"c12", "灯塔节奏", "灯塔规律地亮—灭—亮。", "你的生活里什么节奏让你安心?", "把那个节奏固定进日程一格。"}, } themes := []struct{ title, imagery, prompt, tip string }{ {"山径转弯", "山路在雾里拐了一个弯,远处仍可见脚印。", "你正在避开的弯是什么?", "允许自己慢半步,再决定方向。"}, {"雨后青石", "雨停了,石板反光,空气很干净。", "刚过去的「雨」带走了什么?", "写下三件你想留下的。"}, {"未完成的画", "画布只涂了一角,颜料还湿着。", "哪件事值得继续涂,而不是重开一张?", "今天只加一笔就够。"}, {"旧毛衣", "袖口有点起球,却仍然暖和。", "你生活里哪件「旧物」仍在保护你?", "对它说一句感谢。"}, {"电梯门", "电梯门开了又关,楼层数字在跳。", "你卡在哪一层不愿下去或上去?", "选一个小出口先离开卡点。"}, {"夜班台灯", "台灯只照亮桌面一小块。", "你需要把注意力收窄到哪里?", "设 25 分钟专注,然后休息。"}, {"空座位", "长椅空着一半,另一半有阳光。", "你更需要坐下还是让出位置?", "今天做一个明确的「要/不要」。"}, {"漂流瓶", "瓶子在水里轻轻晃,纸条卷着。", "你想让谁「偶然」读到你的真心?", "写给自己一封短信即可。"}, {"鞋带", "鞋带松了,还在走。", "哪里需要先停下来系紧?", "处理一件基础小事再赶路。"}, {"回声廊", "走廊很长,声音会折返。", "你说出口的话,回来时变成了什么?", "少说一句评判,多问一句好奇。"}, {"晨雾车站", "站台有雾,车还没到。", "等待里你在消耗还是在准备?", "用等待做一件小事(喝水/伸展)。"}, {"书架空隙", "两本书之间留了一指宽空隙。", "你生活里需要留白的是哪一块?", "从日程删掉一个非必要。"}, {"冷水龙头", "水先凉后热,需要等一会儿。", "你对谁/什么事太急了?", "给过程多 10 分钟缓冲。"}, {"风筝线", "线在手里,风筝在高处晃。", "你抓得太紧的是什么?", "刻意松开一点控制,观察结果。"}, {"烛火", "火焰小而稳,周围很暗。", "什么能成为你今晚的小光源?", "做一件温暖且短的事。"}, {"拼图缺角", "图案几乎成型,只差一块。", "缺的那块真的在外面,还是你还没承认?", "列出「已有」而不是「没有」。"}, {"旧车票", "票根折痕深,日期已过。", "哪段旅程可以温柔结束了?", "做一个小小的告别仪式。"}, {"晾衣绳", "衣服在风里慢慢干。", "什么事其实只需要时间,不需要用力?", "今天不催自己一次。"}, {"镜面湖", "湖面几乎无波,倒映天空。", "你害怕看到的自己是哪一面?", "对镜写下三个不评判的观察。"}, {"登山杖", "杖尖戳进土里,借力往上。", "你愿意向谁借一点力?", "发出一个具体求助。"}, {"暖手宝", "掌心慢慢回温。", "谁/什么在给你持续的暖?", "今天主动回馈一点点暖。"}, {"十字路口灯", "红灯停,绿灯行,黄灯提醒。", "你现在更需要停、行,还是减速?", "按信号选一个动作执行。"}, {"背包重量", "肩带勒出印子。", "包里哪样可以先拿出来?", "卸下一件心理负担并告诉自己。"}, {"夜路灯", "一盏盏灯把路分段照亮。", "你下一步只需要照亮多远?", "只计划明天上午即可。"}, {"陶罐裂缝", "裂缝里透出一点光。", "你的「不完美」里藏了什么资源?", "把一个缺点改写成可用特质。"}, {"梯田", "一层一层往上,水流向下。", "你的成长是哪一层该浇水?", "本周只推进一层目标。"}, {"耳机静音", "世界声音被隔开一点。", "你需要隔开什么噪音?", "设定一段无信息时段。"}, {"折纸船", "小船在水盆里打转。", "你把安全感放在哪里?", "做一个可控的小实验。"}, {"墙洞光", "光从窄缝进来,依然够用。", "有限条件下你仍能做什么?", "用现有资源完成一件小事。"}, {"沙漏", "沙子安静落下。", "你在跟时间较劲什么?", "接受「够好」的标准一次。"}, {"回廊座", "走廊尽头有一张椅子。", "你允许自己坐下休息了吗?", "安排 15 分钟真正休息。"}, {"雾中灯塔", "雾浓,灯仍按节奏闪。", "不确定时什么原则仍不变?", "写下你的一条底线。"}, {"青苔石", "石头潮湿,青苔慢慢长。", "哪段关系需要慢养而不是快修?", "降低频率,提高质量。"}, {"晾晒日光", "被子在阳光里松软。", "什么情绪需要「拿出去晒晒」?", "和信任的人聊 10 分钟。"}, {"圆规", "圆心固定,半径可调。", "你的边界圆心在哪里?", "明确一个「可以/不可以」。"}, {"夜航图", "星点标出航线。", "你参考的「星」是谁的标准?", "换成自己的三条原则。"}, {"竹筒饭", "竹香与米香混在一起。", "简单事物里你忽略了什么满足?", "吃一顿专注的饭。"}, {"软垫", "落地时被接住。", "跌倒时谁/什么接住了你?", "写感谢,或成为别人的软垫一次。"}, {"窗格影子", "格子把光切成块。", "你把生活切得太碎了吗?", "合并两件可一起做的事。"}, {"河石", "棱角被水磨圆。", "冲突磨掉了你的什么?留下了什么?", "保留圆润,也保留核心硬度。"}, {"草稿本", "涂改很多,仍翻得开。", "哪份「草稿」其实已经能见人?", "发布一个 70 分版本。"}, {"门铃", "轻按一下就会有回应。", "你在等谁来按铃?还是该自己开门?", "主动发起一次连结。"}, {"冬青", "天冷仍绿。", "什么习惯在难时仍支撑你?", "把那习惯写进日程。"}, {"秋千", "来回摆,最高点短暂。", "你在追峰值还是稳态?", "选择稳态的一小步。"}, {"墨点", "一滴墨在宣纸上晕开。", "影响正在扩大的是什么?", "及时止损或顺势引导。"}, {"绳结", "结打紧了,解需要耐心。", "哪个结值得慢慢解?", "今天只解一层。"}, {"麦浪", "风过,一片起伏。", "集体节奏里你站在哪?", "允许与主流差半拍。"}, {"星尘罐", "罐子装不住光,却闪着。", "你收藏的希望是什么?", "拿出来看一眼,再盖上。"}, {"石阶", "一级一级,并不陡。", "你把台阶看高了吗?", "只上今天这一级。"}, {"暖汤", "热气模糊了眼镜。", "谁需要一碗「暖」?", "做一件具体的照顾。"}, {"风铃", "风来才响。", "什么条件一到你就会行动?", "把条件改成更小的触发。"}, {"书签", "停在未读完的那页。", "你暂停的故事要不要继续?", "读完两页或正式合上。"}, {"露珠", "很快会消失,却折射天空。", "短暂却珍贵的片刻是什么?", "用心经历,不必抓住。"}, {"柴门", "门不华丽,却通向家。", "「回家」对你意味着什么?", "安排一次真正放松的回家感。"}, {"远山", "看得到,走得到需要天。", "远目标如何拆成今日可见的一步?", "写下「今天可见的山脚」。"}, {"井绳", "要用力,也要节奏。", "你在哪用力过猛?", "改成间歇用力。"}, {"纸飞机", "飞不远,但方向是自己的。", "小实验可以朝哪飞?", "发出一个低成本试探。"}, {"苔径", "湿滑,需放慢。", "哪里需要防滑措施?", "加一个缓冲或备份。"}, {"铜铃", "声音清脆后很快静。", "提醒响过,你听见了吗?", "把提醒变成一个行动。"}, {"白瓷碗", "空着时也完整。", "空不是缺失,可能是准备。", "留出空白时段不被填满。"}, {"夜雨窗", "雨点连成线。", "情绪连成片时你怎么分段?", "用呼吸把情绪切成小节。"}, {"藤椅", "坐下就陷进去一点。", "你允许自己「陷进」休息吗?", "无罪恶感地休息 20 分钟。"}, {"路牌", "箭头指向两个镇。", "信息不够时如何选?", "选可逆的那条先走。"}, {"炭火", "表面灰,内里仍热。", "表面平静下还热着的是什么?", "找安全出口表达它。"}, {"蒲公英", "风一吹就出发。", "你准备好松开了吗?", "松开一个过时期待。"}, {"石桥栏", "扶手冰凉但可靠。", "你的可靠支持是什么?", "主动靠一下支持系统。"}, } out := append([]Card{}, base...) for i, th := range themes { out = append(out, Card{ ID: fmt.Sprintf("c%02d", len(base)+i+1), Title: th.title, Imagery: th.imagery, Prompt: th.prompt, Tip: th.tip, }) } // pad to at least 78 with deterministic variants for len(out) < 78 { i := len(out) src := base[i%len(base)] out = append(out, Card{ ID: fmt.Sprintf("c%02d", i+1), Title: fmt.Sprintf("%s·续", src.Title), Imagery: src.Imagery + " 光线又柔和了一点。", Prompt: src.Prompt, Tip: src.Tip, }) } return out[:78] } // Draw picks cards deterministically from userID+scene+day for free single, or 3 for deep. func Draw(userKey, scene string, deep bool, now time.Time) DrawResult { if scene == "" { scene = scenes[0] } n := 1 if deep { n = 3 } day := now.UTC().Format("2006-01-02") idxs := pickIndexes(userKey+"|"+scene+"|"+day, n, len(deck)) cards := make([]Card, 0, n) for _, i := range idxs { cards = append(cards, deck[i]) } summary := map[string]any{ "title": "意象卡片·探索", "headline": fmt.Sprintf("场景「%s」· %s", scene, cards[0].Title), "one_liner": cards[0].Imagery, "overview": fmt.Sprintf("反思:%s", cards[0].Prompt), "life_tip": cards[0].Tip, "keywords": []string{"意象卡片", scene, cards[0].Title}, "scene": scene, "card_ids": idsOf(cards), } var detail map[string]any if deep && len(cards) >= 3 { detail = map[string]any{ "title": "意象卡片·组合反思", "sections": []map[string]any{ {"title": cards[0].Title, "body": cards[0].Imagery + " " + cards[0].Prompt, "bullets": []string{cards[0].Tip}}, {"title": cards[1].Title, "body": cards[1].Imagery + " " + cards[1].Prompt, "bullets": []string{cards[1].Tip}}, {"title": cards[2].Title, "body": cards[2].Imagery + " " + cards[2].Prompt, "bullets": []string{cards[2].Tip}}, {"title": "组合练习", "body": "把三张卡连成一个小故事:发生了什么、你感受到什么、下一步一小步是什么。写 5 行即可。", "bullets": []string{"不评判对错", "只选一个可执行下一步", "需要时可去问答继续聊"}}, }, "conversation_scripts": []string{"我抽到的意象让我想到……", "我现在需要的是……"}, "faq": []map[string]string{ {"q": "这是在预测未来吗?", "a": "不是。意象卡片用于投射与自我反思,不判定好坏,也不预测未来。"}, }, } } return DrawResult{Scene: scene, Cards: cards, Summary: summary, Detail: detail} } func idsOf(cards []Card) []string { out := make([]string, len(cards)) for i, c := range cards { out[i] = c.ID } return out } func pickIndexes(seed string, n, mod int) []int { h := fnv.New32a() _, _ = h.Write([]byte(seed)) start := int(h.Sum32() % uint32(mod)) out := make([]int, 0, n) seen := map[int]bool{} for i := 0; len(out) < n && i < mod*2; i++ { idx := (start + i*3) % mod if seen[idx] { continue } seen[idx] = true out = append(out, idx) } return out }