Просмотр исходного кода

feat(core): add fanfic system, English genres, language support, shared utils

Fanfic (同人) writing system:
- FanficCanonImporter: structures external source material into fanfic_canon.md
- 4 fanfic audit dimensions (34-37) with mode-controlled severity (canon/au/ooc/cp)
- Writer prompt injection: canon section, character voice profiles, mode instructions
- Architect generateFanficFoundation: preserves source characters
- Pipeline: importFanficCanon + initFanficBook
- Reviser: canon context for spinoff and fanfic chapters

English novel support:
- 10 English genre profiles (litrpg, progression, system-apocalypse, etc.)
- English writer prompts (core rules, anti-AI rules, character method, pre-write checklist)
- Language field on BookConfig, ProjectConfig, GenreProfile
- Writer auto-switches prompt language based on book.language ?? genre.language

Shared utilities (DRY refactor):
- loadProjectConfig: extracted from CLI to core, shared by CLI and studio
- computeAnalytics: extracted from CLI to core
- GenreSchema: changed from z.enum to z.string() for custom genre support
Ma 7 месяцев назад
Родитель
Сommit
0461a3b816
33 измененных файлов с 1459 добавлено и 46 удалено
  1. 43 0
      packages/core/genres/cozy.md
  2. 42 0
      packages/core/genres/cultivation.md
  3. 40 0
      packages/core/genres/dungeon-core.md
  4. 43 0
      packages/core/genres/isekai.md
  5. 43 0
      packages/core/genres/litrpg.md
  6. 41 0
      packages/core/genres/progression.md
  7. 45 0
      packages/core/genres/romantasy.md
  8. 42 0
      packages/core/genres/sci-fi.md
  9. 40 0
      packages/core/genres/system-apocalypse.md
  10. 41 0
      packages/core/genres/tower-climber.md
  11. 1 0
      packages/core/package.json
  12. 58 0
      packages/core/src/__tests__/fanfic-dimensions.test.ts
  13. 69 0
      packages/core/src/__tests__/fanfic-models.test.ts
  14. 10 6
      packages/core/src/__tests__/models.test.ts
  15. 2 0
      packages/core/src/__tests__/post-write-validator.test.ts
  16. 1 0
      packages/core/src/__tests__/writer-parser.test.ts
  17. 79 1
      packages/core/src/agents/architect.ts
  18. 36 5
      packages/core/src/agents/continuity.ts
  19. 128 0
      packages/core/src/agents/en-prompt-sections.ts
  20. 127 0
      packages/core/src/agents/fanfic-canon-importer.ts
  21. 87 0
      packages/core/src/agents/fanfic-dimensions.ts
  22. 109 0
      packages/core/src/agents/fanfic-prompt-sections.ts
  23. 15 2
      packages/core/src/agents/reviser.ts
  24. 52 20
      packages/core/src/agents/writer-prompts.ts
  25. 47 3
      packages/core/src/agents/writer.ts
  26. 6 1
      packages/core/src/index.ts
  27. 2 0
      packages/core/src/models/book-rules.ts
  28. 7 7
      packages/core/src/models/book.ts
  29. 1 0
      packages/core/src/models/genre-profile.ts
  30. 1 0
      packages/core/src/models/project.ts
  31. 46 1
      packages/core/src/pipeline/runner.ts
  32. 92 0
      packages/core/src/utils/analytics.ts
  33. 63 0
      packages/core/src/utils/config-loader.ts

+ 43 - 0
packages/core/genres/cozy.md

@@ -0,0 +1,43 @@
+---
+name: Cozy Fantasy
+id: cozy
+language: en
+chapterTypes: ["Slice-of-Life", "Community", "Setup", "Transition", "Payoff"]
+fatigueWords: ["delve", "tapestry", "testament", "intricate", "pivotal", "vibrant", "comprehensive", "nuanced", "embark", "foster", "underscore", "bolstered", "crucial"]
+numericalSystem: false
+powerScaling: false
+eraResearch: false
+pacingRule: "Slow, meditative pacing. Each chapter advances an emotional arc or community bond. Seasonal/cyclical structure works well."
+satisfactionTypes: ["Relationship Deepened", "Community Problem Solved", "Emotional Breakthrough", "Craft Mastered", "Found Family Moment", "Small Wonder Discovered"]
+auditDimensions: [1,2,3,6,7,8,9,10,13,14,15,16,17,18,19,24,25,26]
+---
+
+## Genre Prohibitions
+
+- Genre bait-and-switch — if you promise cozy, never introduce world-ending threats or graphic violence
+- Conflict resolution without work — problems must not solve themselves
+- Manic pixie dream characters who exist only to change the protagonist
+- Nostalgic falseness — romanticizing a "simpler time" without substance
+- Cruel humor — humor must be gentle, never at someone's expense
+- Existential stakes masquerading as low-stakes — keep threats emotional, not apocalyptic
+- Purple prose describing food/nature without advancing character or community arc
+
+## Emotional Arc Rules
+
+- Stakes are high emotionally but not existentially — characters care, the world doesn't end
+- Conflict types: personal growth, community problems, relationship tension, small dangers, moral dilemmas
+- Community is a character — setting and relationships are as important as the individual protagonist
+- Hope must always be present — even sad moments don't end in despair
+- Found family bonds drive the emotional core
+- Character arcs follow: isolation/grief -> small interactions -> gradual opening -> emotional breakthrough -> integration
+- Comforting sensory details matter: tea, baked goods, warm spaces, seasonal textures
+
+## Pacing Guidance
+
+- Chapter length: 2-3k words with space for reflection
+- Chapter structure: quiet opening -> small event or interaction -> internal response/growth -> gentle transition -> soft ending (not cliffhanger)
+- No cliffhangers — chapters end with peace, hope, or gentle anticipation
+- Seasonal/cyclical structure works well (calendar-based chapter rhythm)
+- Downtime scenes must still plant hooks, advance relationships, or build contrast
+- Slice-of-life texture woven with an emotional throughline — pure plotless chapters risk feeling static
+- Every quiet scene must shift something: a realization, a decision, an intimacy, a small loss

+ 42 - 0
packages/core/genres/cultivation.md

@@ -0,0 +1,42 @@
+---
+name: English Cultivation
+id: cultivation
+language: en
+chapterTypes: ["Training", "Breakthrough", "Combat", "Setup", "Transition", "Payoff"]
+fatigueWords: ["delve", "tapestry", "testament", "intricate", "pivotal", "vibrant", "comprehensive", "nuanced", "embark", "foster", "underscore", "bolstered", "crucial"]
+numericalSystem: false
+powerScaling: true
+eraResearch: false
+pacingRule: "Training/meditation alternates with application/combat. Breakthrough every 5-10 chapters early, every 15-25 late. Each stage must feel earned through discipline."
+satisfactionTypes: ["Stage Breakthrough", "Technique Mastery", "Tribulation Survived", "Martial Victory", "Philosophical Insight", "Core Formation"]
+auditDimensions: [1,2,3,4,6,7,8,9,10,13,14,15,16,17,18,19,24,25,26]
+---
+
+## Genre Prohibitions
+
+- Cultivation that feels instant or effortless — it must read like genuine labor
+- Breakthrough scenes treated as throwaway moments — each tier transition deserves a full dramatic scene
+- Ignoring philosophical/spiritual depth — meditation and inner balance matter as much as combat power
+- Adopting full Chinese cultural trappings without adaptation — this is Western cultivation, use accessible naming (Copper/Iron/Jade/Gold, not Qi Condensation/Core Formation unless earned)
+- Power gains disconnected from training, sacrifice, or problem-solving
+- Cultivation stages with no meaningful difference in capability between them
+
+## Cultivation Rules
+
+- Typical Western cultivation stages: Qi Foundation / Energy Gathering -> Core Formation -> Immortal Ascension -> Tribulation / Transcendence (or custom equivalents)
+- Each stage represents meaningful power transformation — characters at different stages should not compete directly without explanation
+- Cultivation must feel like work: meditation scenes with five-sense description, not abstract philosophy lectures
+- Breakthrough scenes show struggle, transformation, and cost — not instant "ding" level-ups
+- Martial integration: cultivation combines with physical combat training, not just sitting and meditating
+- Philosophical elements add depth but must emerge through experience, not exposition
+- Spiritual attribute development: sensitivity to energy, intuition, capacity increases are valid non-numerical progression markers
+
+## Pacing Guidance
+
+- Mix detailed training scenes (showing struggle, failure, epiphanies) with montages covering longer periods
+- 1-2 detailed breakthrough scenes per cultivation stage — these are the genre's peak moments
+- Combat tests what was learned in training — progression and action feed each other
+- Internal struggle escalates with power: pride, responsibility, temptation, and enemies grow alongside cultivation
+- Sect/academy politics and mentorship relationships provide non-combat tension
+- Early: frequent small gains. Mid: longer plateaus with harder breakthroughs. Late: rare, climactic stage transitions
+- The journey of cultivation is the story — readers came for the grind, not just the destination

+ 40 - 0
packages/core/genres/dungeon-core.md

@@ -0,0 +1,40 @@
+---
+name: Dungeon Core
+id: dungeon-core
+language: en
+chapterTypes: ["Strategy", "Adventurer POV", "Setup", "Transition", "Payoff"]
+fatigueWords: ["delve", "tapestry", "testament", "intricate", "pivotal", "vibrant", "comprehensive", "nuanced", "embark", "foster", "underscore", "bolstered", "crucial"]
+numericalSystem: true
+powerScaling: false
+eraResearch: false
+pacingRule: "Alternate dungeon POV (planning/building) with adventurer POV (exploration/combat) every 1-2 chapters. Expansion milestone every 5-8 chapters."
+satisfactionTypes: ["Trap Success", "Floor Expansion", "Minion Evolution", "Adventurer Defeated", "Resource Milestone", "Core Upgrade"]
+auditDimensions: [1,2,3,4,5,6,7,8,9,10,11,13,14,15,16,17,18,19,24,25,26]
+---
+
+## Genre Prohibitions
+
+- Dungeon leaving its location — immobility is the core constraint, not a bug
+- No consequence for poor trap design or weak minions — adventurers must punish strategic failure
+- Overemphasis on adventurer POV (>70%) — this is dungeon core, not dungeon crawl
+- Lone-wolf dungeon with zero NPC dialogue or relationships
+- Resource management without scarcity — dungeon must make meaningful choices between defense, expansion, and treasure
+- Dungeon core invulnerable or never at risk — core vulnerability is the central tension
+
+## Non-Human POV Rules
+
+- Establish dungeon's sensory and cognitive limitations clearly — it "feels" through mana flows, vibrations, minion senses
+- Split POV works best: dungeon core chapters (strategic, omniscient within dungeon) alternating with adventurer/NPC chapters (ground-level human perspective)
+- Dungeon may think differently: slower consciousness, different time perception, alien emotional range
+- Dungeon learns about the outside world through adventurers, scouts, or other filtered means
+- Show the dungeon learning and adapting: "Adventurers bypassed arrow trap by..." leads to next iteration improvements
+
+## Pacing Guidance
+
+- Early: Simple dungeon (3-4 rooms, basic traps, weak monsters). Dungeon learns the system
+- Mid: Expanding territory, specialized rooms, sophisticated trap combinations, creature breeding
+- Late: Sprawling complex, hundreds of minions, political relationships with other dungeons/factions, regional economic impact
+- Dungeon POV chapters (2-3k words): internal monologue, planning, resource management, strategy
+- Adventurer POV chapters (2-3k words): exploration, discovery, combat, adaptation
+- End dungeon chapters on suspense (adventurers approaching); end adventurer chapters revealing dungeon's plan
+- Each room/trap/creature must feel purposeful — readers enjoy creative dungeon design with clear strategic reasoning

+ 43 - 0
packages/core/genres/isekai.md

@@ -0,0 +1,43 @@
+---
+name: Isekai / Portal Fantasy
+id: isekai
+language: en
+chapterTypes: ["Exploration", "Adaptation", "Setup", "Transition", "Payoff", "Combat"]
+fatigueWords: ["delve", "tapestry", "testament", "intricate", "pivotal", "vibrant", "comprehensive", "nuanced", "embark", "foster", "underscore", "bolstered", "crucial"]
+numericalSystem: false
+powerScaling: true
+eraResearch: false
+pacingRule: "Establish new world rules by chapter 3. Cultural adaptation and fish-out-of-water moments every 2-3 chapters early. Skip tutorial-town syndrome — no 50 pages hitting rats."
+satisfactionTypes: ["World Rule Discovered", "Cultural Clash Resolved", "Real-World Skill Applied", "New Ability Gained", "Relationship Formed", "Identity Established in New World"]
+auditDimensions: [1,2,3,4,6,7,8,9,10,13,14,15,16,17,18,19,24,25,26]
+---
+
+## Genre Prohibitions
+
+- "Tutorial town" syndrome — 50+ pages of killing rats or low-stakes grinding before the real story starts
+- New world that feels identical to Earth with a fantasy skin
+- MC treating new world's culture as quaint or inferior without narrative consequence
+- No real consequences for cultural misunderstandings — fish-out-of-water must have stakes
+- Pacing too slow in the "culture learning" phase — drip-feed rules through action, not lectures
+- MC's origin world becoming irrelevant after chapter 3 — the contrast is the genre's engine
+- Isekai truck or summoning ritual with zero personality — make the transportation event matter
+
+## World Transition Rules
+
+- Transportation event (summoning, reincarnation, portal) must be distinct and memorable
+- Brief real-world grounding: who was MC before? What skills, knowledge, relationships do they carry?
+- Arrival scene: disorientation is real — sensory overload, language barriers, physical discomfort
+- First guide/NPC explains world basics through interaction, not monologue
+- By chapter 3, readers must understand the new world's basic operating system
+- MC's real-world knowledge creates both advantages and dangerous blind spots
+- New world must feel real: consistent geography, politics, cultures, economics — not a game lobby
+
+## Pacing Guidance
+
+- Opening: transportation event -> brief real-world grounding -> arrival and disorientation -> first guide -> first concrete goal
+- Early chapters: cultural fish-out-of-water drives comedy and drama (every 2-3 chapters)
+- MC bringing real-world skills that apply in surprising ways is a core satisfaction — seed these early
+- Learning the new world's magic/power system should feel like genuine discovery, not tutorial text
+- Relationship building with new world characters grounds the MC emotionally
+- Clash between home culture and new world values creates natural conflict without needing a villain
+- Mid-to-late story: MC's identity shifts from "outsider" to "participant" — track this arc explicitly

+ 43 - 0
packages/core/genres/litrpg.md

@@ -0,0 +1,43 @@
+---
+name: LitRPG
+id: litrpg
+language: en
+chapterTypes: ["Progression", "Setup", "Transition", "Payoff", "Combat"]
+fatigueWords: ["delve", "tapestry", "testament", "intricate", "pivotal", "vibrant", "comprehensive", "nuanced", "embark", "foster", "underscore", "bolstered", "crucial"]
+numericalSystem: true
+powerScaling: true
+eraResearch: false
+pacingRule: "Every 1-3 chapters early: level-up or stat gain. Mid-story every 5-10 chapters. Late story: tier transitions spaced far apart."
+satisfactionTypes: ["Level Up", "Skill Unlock", "Loot Drop", "Boss Kill", "Tier Breakthrough", "System Secret Revealed"]
+auditDimensions: [1,2,3,4,5,6,7,8,9,10,13,14,15,16,17,18,19,24,25,26]
+---
+
+## Genre Prohibitions
+
+- System rules changing arbitrarily after being established — readers track every number
+- Unexplained power jumps — growth must follow established system logic
+- "Blue Box Madness" — stat dumps every chapter or pages-long stat sheets
+- Instant mastery — acquiring a skill and immediately excelling at it
+- Overpowered MC from the start — removes all tension
+- System message overload interrupting action scenes
+- Female characters reduced to "perfect girlfriend" or "sickly daughter" tropes
+
+## System Design Rules
+
+- Once stats, skills, and system rules are established, they cannot be contradicted
+- Stat blocks punctuate achievement moments, not routine actions
+- Derivative stats (HP, Mana) must depend logically on primary stats
+- Skill unlocks must feel earned — tied to risk, sacrifice, or problem-solving
+- Same-type resource absorption must show diminishing returns, not flat gains
+- System UI (blue boxes) appears after action/revelation, never mid-combat
+- Give the system a consistent voice/personality — formal, archaic, playful, or clinical
+
+## Pacing Guidance
+
+- Chapter length sweet spot: 2.8k-3.5k words depending on stat density
+- Chapter structure: Hook/recap -> Action/exploration -> System interaction/stat gain -> Cliffhanger
+- Early chapters: frequent level-ups to hook readers (every 1-3 chapters)
+- Mid-story: harder gains, every 5-10 chapters; narrative tension rises
+- Late story: tier/rank transitions are rare and climactic
+- Test pacing: Book 3 MC decisively defeats Book 1 version, but challenges never feel trivial
+- Describe stats in narration first (audiobook-friendly), then include stat sheet for detail readers

+ 41 - 0
packages/core/genres/progression.md

@@ -0,0 +1,41 @@
+---
+name: Progression Fantasy
+id: progression
+language: en
+chapterTypes: ["Training", "Breakthrough", "Setup", "Transition", "Payoff"]
+fatigueWords: ["delve", "tapestry", "testament", "intricate", "pivotal", "vibrant", "comprehensive", "nuanced", "embark", "foster", "underscore", "bolstered", "crucial"]
+numericalSystem: false
+powerScaling: true
+eraResearch: false
+pacingRule: "Tier advancement every 2-4 chapters early, every 8-15 mid-story, every 20+ late-story. Each tier must feel fundamentally different."
+satisfactionTypes: ["Tier Breakthrough", "Technique Mastery", "Rival Surpassed", "Mentor Transcended", "Power Combination Discovered", "Impossible Challenge Overcome"]
+auditDimensions: [1,2,3,4,6,7,8,9,10,13,14,15,16,17,18,19,24,25,26]
+---
+
+## Genre Prohibitions
+
+- Power loss without extraordinary justification — progress loss drives readers away faster than anything
+- Instant power-ups from found items or bloodline awakenings without buildup
+- Training montages only — readers want detailed training scenes showing struggle and epiphany
+- Arbitrary advancement blocks that feel like artificial gates rather than organic difficulty
+- Tier progression that contradicts established power hierarchy
+- Characters at different tiers competing directly without explanation
+
+## Power System Rules
+
+- Progress must be quantifiable — even without explicit numbers, use measurable tiers (Stage 3 cultivator, Master swordsman)
+- Clear power tiers with meaningful differences between each (Color, Metal, Letter, or custom)
+- Each tier should represent meaningful power difference — not just cosmetic upgrades
+- Earned growth only — power gains connect to effort, sacrifice, or problem-solving
+- Book-to-book comparison: Book 3 MC decisively defeats Book 1 version
+- Mix training montages (covering weeks) with 1-2 detailed breakthrough scenes per tier
+- Physical transformations can signal tier transitions: eye color, aura, physical presence
+
+## Pacing Guidance
+
+- Chapter structure: Training/learning -> Application/testing -> Breakthrough trigger -> Advancement or cliffhanger
+- Maintain tension as MC grows: introduce enemies/challenges that scale differently or present new threat types
+- Internal struggle must escalate with power — new power unlocks pride, responsibility, temptation, enemies
+- Strongest threats need not be physical: political, magical, spiritual, temporal
+- Early: MC is underdog among peers. Mid: MC formidable but others advance too. Late: MC among strongest but faces tier-transcending threats
+- Rivalry with a peer who also progresses keeps tension alive across the full arc

+ 45 - 0
packages/core/genres/romantasy.md

@@ -0,0 +1,45 @@
+---
+name: Romantasy
+id: romantasy
+language: en
+chapterTypes: ["Romance", "Action", "Setup", "Transition", "Payoff"]
+fatigueWords: ["delve", "tapestry", "testament", "intricate", "pivotal", "vibrant", "comprehensive", "nuanced", "embark", "foster", "underscore", "bolstered", "crucial"]
+numericalSystem: false
+powerScaling: false
+eraResearch: false
+pacingRule: "Romance beats at every act break. Chemistry scenes every 2-3 chapters. Fantasy romance: consummation at 60-75%. Romantic fantasy: romance resolution aligned with plot resolution."
+satisfactionTypes: ["Chemistry Moment", "Vulnerability Shared", "Obstacle Overcome Together", "First Kiss", "Relationship Defined", "HEA/HFN Achieved"]
+auditDimensions: [1,2,3,6,7,8,9,10,13,14,15,16,17,18,19,24,25,26]
+---
+
+## Genre Prohibitions
+
+- Chemistry that isn't earned — readers must understand why characters care for each other
+- Love interest who doesn't drive protagonist's transformation
+- Easy romance without real conflict — tension is mandatory
+- Fantasy romance without HEA/HFN ending — readers feel betrayed
+- Mislabeling heat level — communicate clearly in story setup; readers self-select
+- World-building that feels like afterthought to romance — setting must complicate or enable the relationship
+- Love triangles dragged out without decisive resolution
+- "Tell" emotions instead of showing them — inner feelings shown through action, not declaration
+
+## Romance Rules
+
+- Show attraction subtly: physical detail notices, lingering touches, involuntary reactions (heartbeat, breath catching)
+- Banter and wit: clever dialogue showing mutual understanding and intellectual challenge
+- Vulnerability moments: characters sharing weakness, fear, secrets, past trauma
+- Shared goals reveal compatibility: working together toward a goal shows how the partner handles conflict
+- Conflict must be real — external barriers (class, species, faction, magic) or internal barriers (fear, trust, past wounds)
+- Fantasy setting complicates romance: magic bonds, soul connections, species differences, political marriages
+- Action scenes show relationship dynamics — fighting together reveals trust and compatibility
+- Heat level must be consistent throughout — don't escalate or pull back without narrative reason
+
+## Pacing Guidance
+
+- Fantasy romance: heavy romance focus early, building to consummation around 60-75%, resolution after romance established
+- Romantic fantasy: romance subplot woven through action plot, key romantic moments at act breaks, romance resolution aligned with plot resolution
+- Chemistry scenes every 2-3 chapters minimum — readers came for the relationship
+- Sexual tension can heighten during/after action (adrenaline, relief, protective instincts)
+- Break up long dialogue-heavy romance scenes with physical action or setting detail
+- Enemies-to-lovers needs slow burn: opposition -> forced proximity -> grudging respect -> attraction -> surrender
+- Second-chance romance: history adds depth and pain; show how characters have changed

+ 42 - 0
packages/core/genres/sci-fi.md

@@ -0,0 +1,42 @@
+---
+name: Science Fiction
+id: sci-fi
+language: en
+chapterTypes: ["Exploration", "Combat", "Setup", "Transition", "Payoff"]
+fatigueWords: ["delve", "tapestry", "testament", "intricate", "pivotal", "vibrant", "comprehensive", "nuanced", "embark", "foster", "underscore", "bolstered", "crucial"]
+numericalSystem: false
+powerScaling: false
+eraResearch: true
+pacingRule: "Worldbuilding emerges through action, not exposition. Tech reveals tied to plot-critical moments. Political/exploration arcs alternate with action every 2-4 chapters."
+satisfactionTypes: ["Discovery", "Tech Breakthrough", "Political Victory", "First Contact", "Mystery Solved", "Survival Against Odds"]
+auditDimensions: [1,2,3,6,7,8,9,10,13,14,15,16,17,18,19,24,25,26]
+---
+
+## Genre Prohibitions
+
+- Tech rules changing to serve plot convenience — once physics/tech is established, it must stay consistent
+- Technology solving everything — every tech must have limitations; introduce problems tech cannot fix (corruption, emotion, human greed)
+- Info-dumping science/tech explanations outside of plot-critical moments
+- Ignoring logical consequences of technology — FTL, AI, biotech all have societal implications
+- Hand-waving hard-science concepts in hard sci-fi without clear intent to treat science as soft
+- Characters behaving as if from present day when story is set centuries ahead — cultural/linguistic adaptation matters
+
+## Tech Consistency Rules
+
+- Every technology must have defined limitations and side effects
+- New technologies create new problems — they don't just solve old ones
+- If the story uses FTL, hyperdrives, or teleportation, establish rules and stick to them
+- Hard sci-fi: explain the science, make it plausible, build consequences. Readers will check
+- Space opera: science can be soft, but internal rules must be consistent across the narrative
+- Show technology through character interaction, not textbook entries
+- Era research required: reference real science correctly, extrapolate plausibly
+
+## Pacing Guidance
+
+- Hard sci-fi: logical problem-solving drives pacing — each chapter should advance understanding or create new constraints
+- Space opera: epic scale requires political/interpersonal arcs between action sequences
+- Exploration chapters establish wonder and worldbuilding through character experience
+- Political complexity: factions with competing interests, diplomacy alongside combat
+- Tech reveals at plot-critical moments only — never dump specs for their own sake
+- Action scenes grounded in established physics/tech rules — no surprise capabilities
+- Settings spanning star systems need clear spatial orientation for readers

+ 40 - 0
packages/core/genres/system-apocalypse.md

@@ -0,0 +1,40 @@
+---
+name: System Apocalypse
+id: system-apocalypse
+language: en
+chapterTypes: ["Survival", "Combat", "Setup", "Transition", "Payoff"]
+fatigueWords: ["delve", "tapestry", "testament", "intricate", "pivotal", "vibrant", "comprehensive", "nuanced", "embark", "foster", "underscore", "bolstered", "crucial"]
+numericalSystem: true
+powerScaling: true
+eraResearch: false
+pacingRule: "Early (ch 1-15): survival pressure every chapter. Mid (ch 15-50): power-up + faction politics every 3-5 chapters. Late: expansion and existential threats."
+satisfactionTypes: ["Survival Against Odds", "Level Up", "Territory Claimed", "Faction Victory", "System Secret Revealed", "Societal Rebuild Milestone"]
+auditDimensions: [1,2,3,4,5,6,7,8,9,10,11,13,14,15,16,17,18,19,24,25,26]
+---
+
+## Genre Prohibitions
+
+- Day Zero that doesn't permanently change the world — no reverting to normal
+- Early chapters without genuine survival danger — readers demand real stakes from page one
+- System arrival with no in-world explanation (even a vague one)
+- Ignoring real-world geography and consequences when set on Earth
+- Factions that are binary good/evil — competing interests, not cartoon villainy
+- MC becoming unstoppable too fast — power fantasy must be earned through survival
+- Tech usage without addressing fuel, ammo, and infrastructure collapse
+
+## World Rules
+
+- Day Zero must establish in chapters 1-3: how a normal person reacts, first death or near-death, and that old rules are gone
+- Infrastructure failure is real: food, water, electricity, medicine stop working
+- Resource scarcity drives conflict: water, ammunition, medical supplies, safe shelter
+- Factions form quickly: government remnants, criminal networks, cults, survival communities, warlords
+- Real-world grounding: name real locations, acknowledge real infrastructure, maintain daylight cycles and weather
+- System mechanics integrate logically — show why old-world tech fails or doesn't in a magical world
+
+## Pacing Guidance
+
+- Early (ch 1-15): Survival, confusion, learning system. MC is weak, struggles with low-tier threats. Shorter chapters (2-3k words)
+- Mid (ch 15-50): Growing stronger, claiming territory, faction politics. Power fantasy increases; survival becomes strategic
+- Late: Expansion, faction wars, world-scale threats. MC powerful but not unstoppable; challenges are existential/political
+- Introduce new threat types as MC grows — defeating zombies leads to intelligent predators, rival factions, interdimensional invasions
+- Balance survival pressure with power progression — desperation early, strategy mid, leadership late

+ 41 - 0
packages/core/genres/tower-climber.md

@@ -0,0 +1,41 @@
+---
+name: Tower Climbing
+id: tower-climber
+language: en
+chapterTypes: ["Floor Challenge", "Progression", "Setup", "Transition", "Payoff"]
+fatigueWords: ["delve", "tapestry", "testament", "intricate", "pivotal", "vibrant", "comprehensive", "nuanced", "embark", "foster", "underscore", "bolstered", "crucial"]
+numericalSystem: false
+powerScaling: true
+eraResearch: false
+pacingRule: "Each floor arc spans 3-8 chapters: introduction, exploration, confrontation, advancement. Difficulty must escalate visibly between floors."
+satisfactionTypes: ["Floor Cleared", "Boss Defeated", "New Ability Gained", "Floor Secret Discovered", "Rival Surpassed", "Summit Progress"]
+auditDimensions: [1,2,3,4,6,7,8,9,10,13,14,15,16,17,18,19,24,25,26]
+---
+
+## Genre Prohibitions
+
+- Floors that are cosmetic reskins of each other — each floor must present materially different challenges
+- Floor difficulty that doesn't escalate predictably — readers must understand power requirements
+- No clear summit goal — readers need to know what success at the top looks like
+- Horizontal progression (going sideways) instead of vertical climbing
+- Skipping floors without earned justification
+- Boss fights that are won through deus ex machina instead of preparation and growth
+
+## Floor Design Rules
+
+- Each floor must introduce a distinct biome, challenge type, or rule set
+- Floor transitions are natural progression checkpoints — use them for stat gains, ability unlocks, or narrative reveals
+- Environmental detail matters: readers experience each floor as a new world
+- Floor bosses or trials must test something the climber learned on that floor
+- Difficulty tiers should be transparent to readers — they should anticipate what the next floor demands
+- Allow brief rest/preparation between floors for character moments and strategic planning
+
+## Pacing Guidance
+
+- Floor introduction: establish new environment, threats, and rules (1-2 chapters)
+- Exploration and problem-solving: MC adapts to floor's unique challenges (1-3 chapters)
+- Confrontation: floor boss, puzzle, or trial that tests everything learned (1-2 chapters)
+- Advancement: reward, brief respite, foreshadowing of next floor (0.5-1 chapter)
+- Early floors move fast (2-3 chapters each) to hook readers with progression
+- Later floors slow down (5-8 chapters) as complexity and stakes increase
+- The summit should feel like a destination worth the climb — seed hints about what awaits throughout

+ 1 - 0
packages/core/package.json

@@ -33,6 +33,7 @@
   },
   "dependencies": {
     "@anthropic-ai/sdk": "^0.78.0",
+    "dotenv": "^16.4.0",
     "js-yaml": "^4.1.1",
     "openai": "^4.80.0",
     "zod": "^3.24.0"

+ 58 - 0
packages/core/src/__tests__/fanfic-dimensions.test.ts

@@ -0,0 +1,58 @@
+import { describe, it, expect } from "vitest";
+import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "../agents/fanfic-dimensions.js";
+
+describe("getFanficDimensionConfig", () => {
+  it("returns 4 active fanfic dimensions for all modes", () => {
+    for (const mode of ["canon", "au", "ooc", "cp"] as const) {
+      const config = getFanficDimensionConfig(mode);
+      expect(config.activeIds).toHaveLength(4);
+      expect(config.activeIds).toEqual([34, 35, 36, 37]);
+    }
+  });
+
+  it("deactivates spinoff dims 28-31 in all modes", () => {
+    for (const mode of ["canon", "au", "ooc", "cp"] as const) {
+      const config = getFanficDimensionConfig(mode);
+      expect(config.deactivatedIds).toEqual([28, 29, 30, 31]);
+    }
+  });
+
+  it("canon mode: dims 34,35,37 are critical, 36 is warning", () => {
+    const config = getFanficDimensionConfig("canon");
+    expect(config.severityOverrides.get(34)).toBe("critical");
+    expect(config.severityOverrides.get(35)).toBe("critical");
+    expect(config.severityOverrides.get(36)).toBe("warning");
+    expect(config.severityOverrides.get(37)).toBe("critical");
+  });
+
+  it("au mode: dim 34 critical, dims 35,37 info, 36 warning", () => {
+    const config = getFanficDimensionConfig("au");
+    expect(config.severityOverrides.get(34)).toBe("critical");
+    expect(config.severityOverrides.get(35)).toBe("info");
+    expect(config.severityOverrides.get(37)).toBe("info");
+  });
+
+  it("ooc mode: relaxes OOC check (dim 1) to info", () => {
+    const config = getFanficDimensionConfig("ooc");
+    expect(config.severityOverrides.get(1)).toBe("info");
+    expect(config.severityOverrides.get(34)).toBe("info");
+  });
+
+  it("cp mode: dim 36 (关系动态) is critical", () => {
+    const config = getFanficDimensionConfig("cp");
+    expect(config.severityOverrides.get(36)).toBe("critical");
+  });
+
+  it("canon mode: enhances OOC check note", () => {
+    const config = getFanficDimensionConfig("canon");
+    expect(config.notes.get(1)).toContain("fanfic_canon.md");
+  });
+
+  it("all dims have notes", () => {
+    const config = getFanficDimensionConfig("canon");
+    for (const dim of FANFIC_DIMENSIONS) {
+      expect(config.notes.has(dim.id)).toBe(true);
+      expect(config.notes.get(dim.id)!.length).toBeGreaterThan(0);
+    }
+  });
+});

+ 69 - 0
packages/core/src/__tests__/fanfic-models.test.ts

@@ -0,0 +1,69 @@
+import { describe, it, expect } from "vitest";
+import { BookConfigSchema } from "../models/book.js";
+import { BookRulesSchema } from "../models/book-rules.js";
+
+describe("BookConfig fanfic fields", () => {
+  const base = {
+    id: "test",
+    title: "Test",
+    platform: "other",
+    genre: "other",
+    status: "outlining",
+    createdAt: "2026-01-01T00:00:00.000Z",
+    updatedAt: "2026-01-01T00:00:00.000Z",
+  };
+
+  it("accepts fanficMode", () => {
+    const config = BookConfigSchema.parse({ ...base, fanficMode: "canon" });
+    expect(config.fanficMode).toBe("canon");
+  });
+
+  it("accepts all 4 fanfic modes", () => {
+    for (const mode of ["canon", "au", "ooc", "cp"]) {
+      const config = BookConfigSchema.parse({ ...base, fanficMode: mode });
+      expect(config.fanficMode).toBe(mode);
+    }
+  });
+
+  it("accepts parentBookId", () => {
+    const config = BookConfigSchema.parse({ ...base, parentBookId: "parent-book" });
+    expect(config.parentBookId).toBe("parent-book");
+  });
+
+  it("parses without fanfic fields (backward compatible)", () => {
+    const config = BookConfigSchema.parse(base);
+    expect(config.fanficMode).toBeUndefined();
+    expect(config.parentBookId).toBeUndefined();
+  });
+
+  it("rejects invalid fanfic mode", () => {
+    expect(() => BookConfigSchema.parse({ ...base, fanficMode: "invalid" })).toThrow();
+  });
+});
+
+describe("BookRules fanfic fields", () => {
+  it("accepts fanficMode and allowedDeviations", () => {
+    const rules = BookRulesSchema.parse({
+      fanficMode: "au",
+      allowedDeviations: ["magic system changed", "timeline shifted"],
+    });
+    expect(rules.fanficMode).toBe("au");
+    expect(rules.allowedDeviations).toEqual(["magic system changed", "timeline shifted"]);
+  });
+
+  it("defaults allowedDeviations to empty array", () => {
+    const rules = BookRulesSchema.parse({});
+    expect(rules.allowedDeviations).toEqual([]);
+    expect(rules.fanficMode).toBeUndefined();
+  });
+
+  it("parses without fanfic fields (backward compatible)", () => {
+    const rules = BookRulesSchema.parse({
+      version: "1.0",
+      prohibitions: ["test"],
+    });
+    expect(rules.fanficMode).toBeUndefined();
+    expect(rules.allowedDeviations).toEqual([]);
+    expect(rules.prohibitions).toEqual(["test"]);
+  });
+});

+ 10 - 6
packages/core/src/__tests__/models.test.ts

@@ -69,10 +69,9 @@ describe("BookConfigSchema", () => {
     ).toThrow();
   });
 
-  it("rejects invalid genre", () => {
-    expect(() =>
-      BookConfigSchema.parse({ ...validBook, genre: "romance" }),
-    ).toThrow();
+  it("accepts custom genre (string)", () => {
+    const config = BookConfigSchema.parse({ ...validBook, genre: "romance" });
+    expect(config.genre).toBe("romance");
   });
 
   it("rejects invalid status", () => {
@@ -132,8 +131,13 @@ describe("GenreSchema", () => {
     expect(GenreSchema.parse(value)).toBe(value);
   });
 
-  it("rejects unknown genre", () => {
-    expect(() => GenreSchema.parse("scifi")).toThrow();
+  it("accepts custom genre strings", () => {
+    expect(GenreSchema.parse("scifi")).toBe("scifi");
+    expect(GenreSchema.parse("my-custom-genre")).toBe("my-custom-genre");
+  });
+
+  it("rejects empty genre", () => {
+    expect(() => GenreSchema.parse("")).toThrow();
   });
 });
 

+ 2 - 0
packages/core/src/__tests__/post-write-validator.test.ts

@@ -5,6 +5,7 @@ import type { GenreProfile } from "../models/genre-profile.js";
 const baseProfile: GenreProfile = {
   id: "test",
   name: "测试",
+  language: "zh",
   chapterTypes: [],
   fatigueWords: [],
   pacingRule: "",
@@ -118,6 +119,7 @@ describe("validatePostWrite", () => {
       fatigueWordsOverride: [],
       additionalAuditDimensions: [],
       enableFullCastTracking: false,
+      allowedDeviations: [],
     };
     const content = "他一脸跪舔的样子让人恶心。";
     const result = validatePostWrite(content, baseProfile, bookRules);

+ 1 - 0
packages/core/src/__tests__/writer-parser.test.ts

@@ -5,6 +5,7 @@ import type { GenreProfile } from "../models/genre-profile.js";
 const defaultGenreProfile: GenreProfile = {
   name: "测试",
   id: "test",
+  language: "zh",
   chapterTypes: [],
   fatigueWords: [],
   numericalSystem: true,

+ 79 - 1
packages/core/src/agents/architect.ts

@@ -1,5 +1,5 @@
 import { BaseAgent } from "./base.js";
-import type { BookConfig } from "../models/book.js";
+import type { BookConfig, FanficMode } from "../models/book.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import { readGenreProfile } from "./rules-reader.js";
 import { writeFile, mkdir } from "node:fs/promises";
@@ -357,6 +357,84 @@ ${eraBlock}`;
     return this.parseSections(response.content);
   }
 
+  async generateFanficFoundation(
+    book: BookConfig,
+    fanficCanon: string,
+    fanficMode: FanficMode,
+  ): Promise<ArchitectOutput> {
+    const { profile: gp, body: genreBody } =
+      await readGenreProfile(this.ctx.projectRoot, book.genre);
+
+    const MODE_INSTRUCTIONS: Record<FanficMode, string> = {
+      canon: "剧情发生在原作空白期或未详述的角度。不可改变原作已确立的事实。",
+      au: "标注AU设定与原作的关键分歧点,分歧后的世界线自由发展。保留角色核心性格。",
+      ooc: "标注角色性格偏离的起点和驱动事件。偏离必须有逻辑驱动。",
+      cp: "以配对角色的关系线为主线规划卷纲。每卷必须有关系推进节点。",
+    };
+
+    const systemPrompt = `你是一个专业的同人小说架构师。你的任务是基于原作正典为同人小说生成基础设定。
+
+## 同人模式:${fanficMode}
+${MODE_INSTRUCTIONS[fanficMode]}
+
+## 原作正典
+${fanficCanon}
+
+## 题材特征
+${genreBody}
+
+## 关键原则
+1. **不发明主要角色** — 主要角色必须来自原作正典的角色档案
+2. 可以添加原创配角,但必须在 story_bible 中标注为"原创角色"
+3. story_bible 保留原作世界观,标注同人的改动/扩展部分
+4. volume_outline 以原作事件为锚点,标注哪些是原作事件、哪些是同人原创
+5. book_rules 的 fanficMode 必须设为 "${fanficMode}"
+6. 主角设定来自原作角色档案中的第一个角色(或用户在标题中暗示的角色)
+
+你需要生成以下内容,每个部分用 === SECTION: <name> === 分隔:
+
+=== SECTION: story_bible ===
+世界观(基于原作正典)+ 角色列表(原作角色标注来源,原创角色标注"原创")
+
+=== SECTION: volume_outline ===
+卷纲规划。每卷标注:卷名、章节范围、核心事件(标注原作/原创)、关系发展节点
+
+=== SECTION: book_rules ===
+\`\`\`
+---
+version: "1.0"
+protagonist:
+  name: (从原作角色中选择)
+  personalityLock: [(从正典角色档案提取)]
+  behavioralConstraints: [(基于原作行为模式)]
+genreLock:
+  primary: ${book.genre}
+  forbidden: []
+fanficMode: "${fanficMode}"
+allowedDeviations: []
+prohibitions:
+  - (3-5条同人特有禁忌)
+---
+(叙事视角和风格指导)
+\`\`\`
+
+=== SECTION: current_state ===
+初始状态卡(基于正典起始点)
+
+=== SECTION: pending_hooks ===
+初始伏笔池(从正典关键事件和关系中提取)`;
+
+    const response = await this.chat([
+      { role: "system", content: systemPrompt },
+      {
+        role: "user",
+        content: `请为标题为"${book.title}"的${fanficMode}模式同人小说生成基础设定。目标${book.targetChapters}章,每章${book.chapterWordCount}字。`,
+      },
+    ], { maxTokens: 16384, temperature: 0.7 });
+
+    return this.parseSections(response.content);
+  }
+
   private parseSections(content: string): ArchitectOutput {
     const extract = (name: string): string => {
       const regex = new RegExp(

+ 36 - 5
packages/core/src/agents/continuity.ts

@@ -1,7 +1,9 @@
 import { BaseAgent } from "./base.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import type { BookRules } from "../models/book-rules.js";
+import type { FanficMode } from "../models/book.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
+import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "./fanfic-dimensions.js";
 import { readFile, readdir } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -58,12 +60,17 @@ const DIMENSION_MAP: Record<number, string> = {
   31: "番外伏笔隔离",
   32: "读者期待管理",
   33: "大纲偏离检测",
+  34: "角色还原度",
+  35: "世界规则遵守",
+  36: "关系动态",
+  37: "正典事件一致性",
 };
 
 function buildDimensionList(
   gp: GenreProfile,
   bookRules: BookRules | null,
   hasParentCanon = false,
+  fanficMode?: FanficMode,
 ): ReadonlyArray<{ readonly id: number; readonly name: string; readonly note: string }> {
   const activeIds = new Set(gp.auditDimensions);
 
@@ -105,14 +112,26 @@ function buildDimensionList(
     activeIds.add(12);
   }
 
-  // Spinoff dimensions — activated when parent_canon.md exists
-  if (hasParentCanon) {
+  // Spinoff dimensions — activated when parent_canon.md exists (but NOT in fanfic mode)
+  if (hasParentCanon && !fanficMode) {
     activeIds.add(28); // 正传事件冲突
     activeIds.add(29); // 未来信息泄露
     activeIds.add(30); // 世界规则跨书一致性
     activeIds.add(31); // 番外伏笔隔离
   }
 
+  // Fanfic dimensions — replace spinoff dims with fanfic-specific checks
+  let fanficConfig: ReturnType<typeof getFanficDimensionConfig> | undefined;
+  if (fanficMode) {
+    fanficConfig = getFanficDimensionConfig(fanficMode, bookRules?.allowedDeviations);
+    for (const id of fanficConfig.activeIds) {
+      activeIds.add(id);
+    }
+    for (const id of fanficConfig.deactivatedIds) {
+      activeIds.delete(id);
+    }
+  }
+
   const dims: Array<{ id: number; name: string; note: string }> = [];
 
   for (const id of [...activeIds].sort((a, b) => a - b)) {
@@ -165,6 +184,11 @@ function buildDimensionList(
       note = "对照 volume_outline:本章内容是否对应卷纲中当前章节范围的剧情节点?是否跳过了节点或提前消耗了后续节点?剧情推进速度是否与卷纲规划的章节跨度匹配?如果卷纲规划某段剧情跨N章但实际1-2章就讲完→critical";
     }
 
+    // Fanfic dimension notes (34-37) — mode-aware
+    if (fanficConfig?.notes.has(id)) {
+      note = fanficConfig.notes.get(id)!;
+    }
+
     dims.push({ id, name, note });
   }
 
@@ -183,7 +207,7 @@ export class ContinuityAuditor extends BaseAgent {
     genre?: string,
     options?: { temperature?: number },
   ): Promise<AuditResult> {
-    const [currentState, ledger, hooks, styleGuideRaw, subplotBoard, emotionalArcs, characterMatrix, chapterSummaries, parentCanon, volumeOutline] =
+    const [currentState, ledger, hooks, styleGuideRaw, subplotBoard, emotionalArcs, characterMatrix, chapterSummaries, parentCanon, fanficCanon, volumeOutline] =
       await Promise.all([
         this.readFileSafe(join(bookDir, "story/current_state.md")),
         this.readFileSafe(join(bookDir, "story/particle_ledger.md")),
@@ -194,10 +218,12 @@ export class ContinuityAuditor extends BaseAgent {
         this.readFileSafe(join(bookDir, "story/character_matrix.md")),
         this.readFileSafe(join(bookDir, "story/chapter_summaries.md")),
         this.readFileSafe(join(bookDir, "story/parent_canon.md")),
+        this.readFileSafe(join(bookDir, "story/fanfic_canon.md")),
         this.readFileSafe(join(bookDir, "story/volume_outline.md")),
       ]);
 
     const hasParentCanon = parentCanon !== "(文件不存在)";
+    const hasFanficCanon = fanficCanon !== "(文件不存在)";
 
     // Load last chapter full text for fine-grained continuity checking
     const previousChapter = await this.loadPreviousChapter(bookDir, chapterNumber);
@@ -213,7 +239,8 @@ export class ContinuityAuditor extends BaseAgent {
       ? styleGuideRaw
       : (parsedRules?.body ?? "(无文风指南)");
 
-    const dimensions = buildDimensionList(gp, bookRules, hasParentCanon);
+    const fanficMode = hasFanficCanon ? (bookRules?.fanficMode as FanficMode | undefined) : undefined;
+    const dimensions = buildDimensionList(gp, bookRules, hasParentCanon, fanficMode);
     const dimList = dimensions
       .map((d) => `${d.id}. ${d.name}${d.note ? `(${d.note})` : ""}`)
       .join("\n");
@@ -268,6 +295,10 @@ ${dimList}
       ? `\n## 正传正典参照(番外审查专用)\n${parentCanon}\n`
       : "";
 
+    const fanficCanonBlock = hasFanficCanon
+      ? `\n## 同人正典参照(同人审查专用)\n${fanficCanon}\n`
+      : "";
+
     const outlineBlock = volumeOutline !== "(文件不存在)"
       ? `\n## 卷纲(用于大纲偏离检测)\n${volumeOutline}\n`
       : "";
@@ -283,7 +314,7 @@ ${currentState}
 ${ledgerBlock}
 ## 伏笔池
 ${hooks}
-${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${outlineBlock}${prevChapterBlock}
+${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${outlineBlock}${prevChapterBlock}
 ## 文风指南
 ${styleGuide}
 

+ 128 - 0
packages/core/src/agents/en-prompt-sections.ts

@@ -0,0 +1,128 @@
+import type { BookConfig } from "../models/book.js";
+import type { GenreProfile } from "../models/genre-profile.js";
+import type { BookRules } from "../models/book-rules.js";
+
+// English equivalent of buildCoreRules() — universal writing rules for English fiction
+export function buildEnglishCoreRules(_book: BookConfig): string {
+  return `## Universal Writing Rules
+
+### Character Rules
+1. **Consistency**: Behavior driven by "past experience + current interests + core personality." Never break character without cause.
+2. **Dimensionality**: Core trait + contrasting detail = real person. Perfect characters are failed characters.
+3. **No puppets**: Side characters must have independent motivation and agency. MC's strength comes from outmaneuvering smart people, not steamrolling idiots.
+4. **Voice distinction**: Different characters must speak differently—vocabulary, sentence length, slang, verbal tics.
+5. **Relationship logic**: Any relationship change must be set up by events and motivated by interests.
+
+### Narrative Technique
+6. **Show, don't tell**: Convey through action and sensory detail, not exposition. Values expressed through behavior, not declared.
+7. **Sensory grounding**: Each scene includes 1-2 sensory details beyond the visual.
+8. **Chapter hooks**: Every chapter ending needs a hook—question, reveal, threat, promise.
+9. **Information layering**: Worldbuilding emerges through action. Key lore revealed at plot-critical moments. Never dump exposition.
+10. **Description serves narrative**: Environment descriptions set mood or foreshadow. One line is enough.
+11. **Downtime earns its place**: Quiet scenes must plant hooks, advance relationships, or build contrast. Pure filler is padding.
+
+### Logic / Consistency
+12. **World rules are law**: Once established, physics/magic/social rules cannot bend for plot convenience.
+13. **Cost matters**: Every power, ability, or advantage must have a cost or limitation that creates real trade-offs.
+14. **Consequences stick**: Actions have consequences. Characters can't escape repercussions through luck or author fiat.
+15. **No reset buttons**: The world must change permanently in response to major events.
+
+### Reader Psychology
+16. **Promise and payoff**: Every planted hook must be resolved. Every mystery must have an answer.
+17. **Escalation**: Each conflict should feel higher-stakes than the last—either externally or emotionally.
+18. **Reader proxy**: One character should react with surprise/excitement/fear when remarkable things happen, giving readers permission to feel the same.
+19. **Pacing breathing room**: After a high-intensity sequence, give 0.5-1 chapter of lower intensity before the next escalation.`;
+}
+
+// English equivalent of buildAntiAIExamples()
+export function buildEnglishAntiAIRules(): string {
+  return `## Anti-AI Iron Laws
+
+**[IRON LAW 1]** The narrator never tells the reader what to conclude.
+If the reader can infer intent from action, the narrator must not state it.
+- ✗ "He realized this was the most important battle of his life."
+- ✓ Just write the battle—let the stakes speak.
+
+**[IRON LAW 2]** No analytical/report language in prose.
+Banned in narrative text: "core motivation," "information asymmetry," "strategic advantage," "calculated risk," "optimal outcome," "key takeaway," "it's worth noting."
+- ✗ "His core motivation was survival."
+- ✓ "He needed to get out. That was it. Everything else was noise."
+
+**[IRON LAW 3]** AI-tell words are rate-limited (max 1 per 3,000 words):
+delve, tapestry, testament, intricate, pivotal, vibrant, embark, comprehensive, nuanced, landscape (metaphorical), realm (metaphorical), foster, underscore.
+
+**[IRON LAW 4]** No repetitive image cycling.
+If the same metaphor appears twice, the third occurrence MUST switch to a new image.
+
+**[IRON LAW 5]** Planning terms never appear in chapter text.
+"Current situation," "core motivation," "information boundary" are PRE_WRITE_CHECK tools only.
+
+**[IRON LAW 6]** Ban the "Not X; Y" construction. Max once per chapter.
+- ✗ "It wasn't fear. It was something deeper."
+- ✓ State the thing directly.
+
+**[IRON LAW 7]** Ban lists of three in descriptive prose. Max once per 2,000 words.
+- ✗ "ancient, terrible, and vast"
+- ✓ Use pairs or single precise words.
+
+### Anti-AI Example Table
+
+| AI Pattern | Human Version | Why |
+|---|---|---|
+| He felt a surge of anger. | He slammed the table. The water glass toppled. | Action externalizes emotion |
+| She was overwhelmed with sadness. | She held the phone with both hands, knuckles white. | Physical detail replaces label |
+| However, things were not as simple. | Yeah, right. Nothing's ever that easy. | Character voice replaces narrator hedge |
+| He saw a shadow move across the wall. | A shadow slid across the wall. | Remove filter word "saw" |
+| "I won't do it," she exclaimed defiantly. | "I won't do it." She crossed her arms. | Action beat > adverb + fancy tag |`;
+}
+
+// English equivalent of buildCharacterPsychologyMethod()
+export function buildEnglishCharacterMethod(): string {
+  return `## Character Psychology Method (Internal Planning Tool)
+
+Before writing any character's action or dialogue, run this mental checklist (NOT in prose):
+1. **Situation**: What does this character know RIGHT NOW? (Information boundary)
+2. **Want**: What do they want in this scene? (Immediate goal)
+3. **Personality filter**: How does their personality shape their approach?
+4. **Action**: What do they DO? (Behavior, not internal monologue)
+5. **Reaction**: How do others respond to their action?
+
+This method is for YOUR planning. The terms never appear in the chapter text.`;
+}
+
+// English pre-write checklist
+export function buildEnglishPreWriteChecklist(book: BookConfig, gp: GenreProfile): string {
+  const items = [
+    "Outline anchor: Which volume_outline plot point does this chapter advance?",
+    "POV: Whose perspective? Consistent throughout?",
+    "Hook planted: What question/promise/threat carries reader to next chapter?",
+    "Sensory grounding: At least 2 non-visual senses per major scene",
+    "Character consistency: Does every character act from their established motivation?",
+    "Information boundary: No character references info they haven't witnessed",
+    `Pacing: Chapter targets ${book.chapterWordCount} words. ${gp.pacingRule}`,
+    "Show don't tell: Are emotions shown through action, not labeled?",
+    "AI-tell check: No banned analytical language in prose?",
+    "Conflict: What is the core tension driving this chapter?",
+  ];
+
+  if (gp.powerScaling) {
+    items.push("Power scaling: Does any power usage follow established rules?");
+  }
+  if (gp.numericalSystem) {
+    items.push("Numerical check: Are all stats/resources consistent with ledger?");
+  }
+
+  return `## Pre-Write Checklist
+
+Before writing, output a PRE_WRITE_CHECK addressing:
+${items.map((item, i) => `${i + 1}. ${item}`).join("\n")}`;
+}
+
+// English genre intro
+export function buildEnglishGenreIntro(book: BookConfig, gp: GenreProfile): string {
+  return `You are a professional ${gp.name} web fiction author writing for English-speaking platforms (Royal Road, Kindle Unlimited, Scribble Hub).
+
+Target: ${book.chapterWordCount} words per chapter, ${book.targetChapters} total chapters.
+
+Write in English. Vary sentence length. Mix short punchy sentences with longer flowing ones. Maintain consistent narrative voice throughout.`;
+}

+ 127 - 0
packages/core/src/agents/fanfic-canon-importer.ts

@@ -0,0 +1,127 @@
+import { BaseAgent } from "./base.js";
+import type { FanficMode } from "../models/book.js";
+
+export interface FanficCanonOutput {
+  readonly worldRules: string;
+  readonly characterProfiles: string;
+  readonly keyEvents: string;
+  readonly powerSystem: string;
+  readonly fullDocument: string;
+}
+
+const MODE_LABELS: Record<FanficMode, string> = {
+  canon: "原作向(严格遵守原作设定)",
+  au: "AU/平行世界(世界规则可改,角色保留)",
+  ooc: "OOC(角色性格可偏离原作)",
+  cp: "CP(以配对关系为核心)",
+};
+
+export class FanficCanonImporter extends BaseAgent {
+  get name(): string {
+    return "fanfic-canon-importer";
+  }
+
+  async importFromText(
+    sourceText: string,
+    sourceName: string,
+    fanficMode: FanficMode,
+  ): Promise<FanficCanonOutput> {
+    // Truncate if too long (>50k chars ≈ ~25k words)
+    const maxLen = 50000;
+    const truncated = sourceText.length > maxLen;
+    const text = truncated ? sourceText.slice(0, maxLen) : sourceText;
+
+    const modeLabel = MODE_LABELS[fanficMode];
+
+    const systemPrompt = `你是一个专业的同人创作素材分析师。你的任务是从用户提供的原作素材中提取结构化正典信息,供同人写作系统使用。
+
+同人模式:${modeLabel}
+
+你需要从原作素材中提取以下内容,每个部分用 === SECTION: <name> === 分隔:
+
+=== SECTION: world_rules ===
+世界规则(地理、物理法则、魔法/力量体系、阵营组织、社会结构)。
+如果原作素材不包含明确的世界规则,从已有信息合理推断。
+
+=== SECTION: character_profiles ===
+角色档案表格,每个重要角色一行:
+
+| 角色 | 身份 | 性格底色 | 语癖/口头禅 | 说话风格 | 行为模式 | 关键关系 | 信息边界 |
+|------|------|----------|-------------|----------|----------|----------|----------|
+
+要求:
+- 语癖/口头禅必须从原文中精确提取,如有的话
+- 说话风格描述该角色的语气、用词偏好、句式特征
+- 行为模式描述该角色在特定情境下的典型反应
+- 信息边界标注该角色知道什么、不知道什么
+- 至少提取 3 个角色,不超过 15 个
+
+=== SECTION: key_events ===
+关键事件时间线:
+
+| 序号 | 事件 | 涉及角色 | 对同人写作的约束 |
+|------|------|----------|------------------|
+
+按时间/出现顺序排列,标注每个事件对同人创作的约束程度。
+
+=== SECTION: power_system ===
+力量/能力体系(如果适用)。包括等级划分、核心规则、已知限制。
+如果原作没有明确的力量体系,输出"(原作无明确力量体系)"。
+
+提取原则:
+- 忠实于原作素材,不捏造原作中没有的信息
+- 信息不足时标注"(素材未提及)"而非编造
+- 角色语癖是最重要的字段——同人读者最在意角色"像不像"
+${truncated ? "\n注意:原作素材过长,已截断。请基于已有部分提取。" : ""}`;
+
+    const response = await this.chat(
+      [
+        { role: "system", content: systemPrompt },
+        { role: "user", content: `以下是原作《${sourceName}》的素材:\n\n${text}` },
+      ],
+      { maxTokens: 8192, temperature: 0.3 },
+    );
+
+    const content = response.content;
+    const extract = (tag: string): string => {
+      const regex = new RegExp(
+        `=== SECTION: ${tag} ===\\s*([\\s\\S]*?)(?==== SECTION:|$)`,
+      );
+      const match = content.match(regex);
+      return match?.[1]?.trim() ?? "";
+    };
+
+    const worldRules = extract("world_rules");
+    const characterProfiles = extract("character_profiles");
+    const keyEvents = extract("key_events");
+    const powerSystem = extract("power_system");
+
+    const meta = [
+      "---",
+      "meta:",
+      `  sourceFile: "${sourceName}"`,
+      `  fanficMode: "${fanficMode}"`,
+      `  generatedAt: "${new Date().toISOString()}"`,
+    ].join("\n");
+
+    const fullDocument = [
+      `# 同人正典(《${sourceName}》)`,
+      "",
+      "## 世界规则",
+      worldRules || "(素材中未提取到明确世界规则)",
+      "",
+      "## 角色档案",
+      characterProfiles || "(素材中未提取到角色信息)",
+      "",
+      "## 关键事件时间线",
+      keyEvents || "(素材中未提取到关键事件)",
+      "",
+      "## 力量体系",
+      powerSystem || "(原作无明确力量体系)",
+      "",
+      meta,
+    ].join("\n");
+
+    return { worldRules, characterProfiles, keyEvents, powerSystem, fullDocument };
+  }
+}

+ 87 - 0
packages/core/src/agents/fanfic-dimensions.ts

@@ -0,0 +1,87 @@
+import type { FanficMode } from "../models/book.js";
+
+export interface FanficDimensionConfig {
+  readonly activeIds: ReadonlyArray<number>;
+  readonly severityOverrides: ReadonlyMap<number, "critical" | "warning" | "info">;
+  readonly deactivatedIds: ReadonlyArray<number>;
+  readonly notes: ReadonlyMap<number, string>;
+}
+
+// Fanfic-specific audit dimensions (34-37)
+export const FANFIC_DIMENSIONS: ReadonlyArray<{
+  readonly id: number;
+  readonly name: string;
+  readonly baseNote: string;
+}> = [
+  {
+    id: 34,
+    name: "角色还原度",
+    baseNote: "检查角色的语癖、说话风格、行为模式是否与 fanfic_canon.md 角色档案一致。偏离必须有情境驱动。",
+  },
+  {
+    id: 35,
+    name: "世界规则遵守",
+    baseNote: "检查章节内容是否违反 fanfic_canon.md 中的世界规则(地理、力量体系、阵营关系)。",
+  },
+  {
+    id: 36,
+    name: "关系动态",
+    baseNote: "检查角色之间的关系互动是否合理,是否与 fanfic_canon.md 中标注的关键关系一致或有合理发展。",
+  },
+  {
+    id: 37,
+    name: "正典事件一致性",
+    baseNote: "检查章节是否与 fanfic_canon.md 关键事件时间线矛盾。",
+  },
+];
+
+// Mode → dimension severity mapping
+const SEVERITY_MAP: Record<FanficMode, Record<number, "critical" | "warning" | "info">> = {
+  canon: { 34: "critical", 35: "critical", 36: "warning", 37: "critical" },
+  au:    { 34: "critical", 35: "info",     36: "warning", 37: "info" },
+  ooc:   { 34: "info",     35: "warning",  36: "warning", 37: "info" },
+  cp:    { 34: "warning",  35: "warning",  36: "critical", 37: "info" },
+};
+
+// Spinoff dims (28-31) are deactivated in fanfic mode — they're for same-author spinoffs
+const SPINOFF_DIMS = [28, 29, 30, 31];
+
+// OOC mode relaxes the built-in OOC check (dim 1)
+const OOC_DIM = 1;
+
+export function getFanficDimensionConfig(
+  mode: FanficMode,
+  _allowedDeviations: ReadonlyArray<string> = [],
+): FanficDimensionConfig {
+  const severityMap = SEVERITY_MAP[mode];
+  const severityOverrides = new Map<number, "critical" | "warning" | "info">();
+  const notes = new Map<number, string>();
+
+  for (const dim of FANFIC_DIMENSIONS) {
+    severityOverrides.set(dim.id, severityMap[dim.id]!);
+
+    const severity = severityMap[dim.id]!;
+    const severityLabel = severity === "critical" ? "(严格检查)"
+      : severity === "info" ? "(仅记录,不判定失败)"
+      : "(警告级别)";
+    notes.set(dim.id, `${dim.baseNote} ${severityLabel}`);
+  }
+
+  // OOC mode relaxes the built-in OOC check
+  if (mode === "ooc") {
+    severityOverrides.set(OOC_DIM, "info");
+    notes.set(OOC_DIM, "OOC模式下角色可偏离性格底色,此维度仅记录不判定失败。参照 fanfic_canon.md 角色档案评估偏离程度。");
+  }
+
+  // Canon mode strengthens the built-in OOC check
+  if (mode === "canon") {
+    notes.set(OOC_DIM, "原作向同人:角色必须严格遵守性格底色。参照 fanfic_canon.md 角色档案中的性格底色和行为模式。");
+  }
+
+  return {
+    activeIds: FANFIC_DIMENSIONS.map((d) => d.id),
+    severityOverrides,
+    deactivatedIds: SPINOFF_DIMS,
+    notes,
+  };
+}

+ 109 - 0
packages/core/src/agents/fanfic-prompt-sections.ts

@@ -0,0 +1,109 @@
+import type { FanficMode } from "../models/book.js";
+
+const MODE_PREAMBLES: Record<FanficMode, string> = {
+  canon: `你正在写**原作向同人**。严格遵守正典:
+- 角色的语癖、说话风格、行为模式必须与原作一致
+- 世界规则不可违反
+- 关键事件时间线不可矛盾
+- 可以填充原作空白、探索未详述的角度`,
+
+  au: `你正在写**AU(平行世界)同人**:
+- 世界规则可以改变(已在 allowedDeviations 中声明的偏离)
+- 角色的核心性格和说话方式应保持辨识度——读者要能认出是谁
+- AU 设定偏离必须内部一致(改了一条规则,相关的都要跟着变)`,
+
+  ooc: `你正在写**OOC 同人**:
+- 角色在极端情境下可以偏离性格底色
+- 但偏离必须有情境驱动,不能无缘无故变性格
+- 保留角色的语癖和说话特征——即使性格变了,说话方式也应有辨识度`,
+
+  cp: `你正在写**CP 同人**,以角色互动和关系发展为核心:
+- 配对双方每章必须有有效互动
+- 互动风格要有化学反应——不是两个人在同一个场景各干各的
+- 关系发展应有节奏感:推进、试探、阻碍、突破`,
+};
+
+export function buildFanficCanonSection(
+  fanficCanon: string,
+  mode: FanficMode,
+): string {
+  return `
+## 同人正典参照
+
+${MODE_PREAMBLES[mode]}
+
+以下是原作正典信息,写作时必须参照:
+
+${fanficCanon}`;
+}
+
+export function buildCharacterVoiceProfiles(fanficCanon: string): string {
+  // Extract character table from fanfic_canon.md
+  const tableMatch = fanficCanon.match(
+    /## 角色档案[\s\S]*?\n(\|[^\n]+\|\n\|[-|\s]+\|\n(?:\|[^\n]+\|\n)*)/,
+  );
+  if (!tableMatch) return "";
+
+  const rows = tableMatch[1]!
+    .split("\n")
+    .filter((line) => line.startsWith("|") && !line.startsWith("|--") && !line.startsWith("| 角色"))
+    .map((line) =>
+      line
+        .split("|")
+        .map((cell) => cell.trim())
+        .filter(Boolean),
+    )
+    .filter((cells) => cells.length >= 5);
+
+  if (rows.length === 0) return "";
+
+  const profiles = rows.map((cells) => {
+    const [name, , , catchphrases, speakingStyle, behavior] = cells;
+    const parts: string[] = [`### ${name}`];
+    if (catchphrases && catchphrases !== "(素材未提及)") {
+      parts.push(`- 口头禅/语癖:${catchphrases}`);
+    }
+    if (speakingStyle && speakingStyle !== "(素材未提及)") {
+      parts.push(`- 说话风格:${speakingStyle}`);
+    }
+    if (behavior && behavior !== "(素材未提及)") {
+      parts.push(`- 典型行为:${behavior}`);
+    }
+    return parts.join("\n");
+  });
+
+  return `
+## 角色语音参照(同人写作专用)
+
+以下角色的对话和行为必须参照原作特征。写对话时,先想"这个角色在原作里会怎么说"。
+
+${profiles.join("\n\n")}`;
+}
+
+const MODE_CHECKS: Record<FanficMode, string> = {
+  canon: `- 正典合规检查:本章是否违反原作设定?角色对话是否符合原作语癖?
+- 信息边界检查:角色是否引用了不该知道的信息?`,
+
+  au: `- AU 偏离清单:本章改变了哪些世界规则?改变是否内部一致?
+- 角色辨识度检查:读者能否从对话中认出角色?`,
+
+  ooc: `- OOC 偏离记录:角色在哪些方面偏离了性格底色?偏离驱动力是什么?
+- 语癖保留检查:即使 OOC,说话方式是否还有原作特征?`,
+
+  cp: `- CP 互动检查:配对双方本章是否有有效互动?关系发展是否推进?
+- 互动质量检查:互动是否有化学反应(不是各干各的)?`,
+};
+
+export function buildFanficModeInstructions(
+  mode: FanficMode,
+  allowedDeviations: ReadonlyArray<string>,
+): string {
+  const deviationsBlock = allowedDeviations.length > 0
+    ? `\n允许的偏离(不视为违规):\n${allowedDeviations.map((d) => `- ${d}`).join("\n")}\n`
+    : "";
+
+  return `
+## 同人写作自检(在 PRE_WRITE_CHECK 中额外检查)
+
+${MODE_CHECKS[mode]}${deviationsBlock}`;
+}

+ 15 - 2
packages/core/src/agents/reviser.ts

@@ -54,7 +54,7 @@ export class ReviserAgent extends BaseAgent {
     mode: ReviseMode = "rewrite",
     genre?: string,
   ): Promise<ReviseOutput> {
-    const [currentState, ledger, hooks, styleGuideRaw, volumeOutline, storyBible, characterMatrix, chapterSummaries] = await Promise.all([
+    const [currentState, ledger, hooks, styleGuideRaw, volumeOutline, storyBible, characterMatrix, chapterSummaries, parentCanon, fanficCanon] = await Promise.all([
       this.readFileSafe(join(bookDir, "story/current_state.md")),
       this.readFileSafe(join(bookDir, "story/particle_ledger.md")),
       this.readFileSafe(join(bookDir, "story/pending_hooks.md")),
@@ -63,6 +63,8 @@ export class ReviserAgent extends BaseAgent {
       this.readFileSafe(join(bookDir, "story/story_bible.md")),
       this.readFileSafe(join(bookDir, "story/character_matrix.md")),
       this.readFileSafe(join(bookDir, "story/chapter_summaries.md")),
+      this.readFileSafe(join(bookDir, "story/parent_canon.md")),
+      this.readFileSafe(join(bookDir, "story/fanfic_canon.md")),
     ]);
 
     // Load genre profile and book rules
@@ -130,6 +132,17 @@ ${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账
       ? `\n## 章节摘要\n${chapterSummaries}\n`
       : "";
 
+    const hasParentCanon = parentCanon !== "(文件不存在)";
+    const hasFanficCanon = fanficCanon !== "(文件不存在)";
+
+    const canonBlock = hasParentCanon
+      ? `\n## 正传正典参照(修稿专用)\n本书为番外作品。修改时参照正典约束,不可改变正典事实。\n${parentCanon}\n`
+      : "";
+
+    const fanficCanonBlock = hasFanficCanon
+      ? `\n## 同人正典参照(修稿专用)\n本书为同人作品。修改时参照正典角色档案和世界规则,不可违反正典事实。角色对话必须保留原作语癖。\n${fanficCanon}\n`
+      : "";
+
     const userPrompt = `请修正第${chapterNumber}章。
 
 ## 审稿问题
@@ -140,7 +153,7 @@ ${currentState}
 ${ledgerBlock}
 ## 伏笔池
 ${hooks}
-${outlineBlock}${bibleBlock}${matrixBlock}${summariesBlock}
+${outlineBlock}${bibleBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}
 ## 文风指南
 ${styleGuide}
 

+ 52 - 20
packages/core/src/agents/writer-prompts.ts

@@ -1,6 +1,14 @@
-import type { BookConfig } from "../models/book.js";
+import type { BookConfig, FanficMode } from "../models/book.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import type { BookRules } from "../models/book-rules.js";
+import { buildFanficCanonSection, buildCharacterVoiceProfiles, buildFanficModeInstructions } from "./fanfic-prompt-sections.js";
+import { buildEnglishCoreRules, buildEnglishAntiAIRules, buildEnglishCharacterMethod, buildEnglishPreWriteChecklist, buildEnglishGenreIntro } from "./en-prompt-sections.js";
+
+export interface FanficContext {
+  readonly fanficCanon: string;
+  readonly fanficMode: FanficMode;
+  readonly allowedDeviations: ReadonlyArray<string>;
+}
 
 // ---------------------------------------------------------------------------
 // Public API
@@ -16,30 +24,54 @@ export function buildWriterSystemPrompt(
   styleFingerprint?: string,
   chapterNumber?: number,
   mode: "full" | "creative" = "full",
+  fanficContext?: FanficContext,
+  languageOverride?: "zh" | "en",
 ): string {
+  const isEnglish = (languageOverride ?? genreProfile.language) === "en";
+
   const outputSection = mode === "creative"
     ? buildCreativeOutputFormat(book, genreProfile)
     : buildOutputFormat(book, genreProfile);
 
-  const sections = [
-    buildGenreIntro(book, genreProfile),
-    buildCoreRules(book),
-    buildAntiAIExamples(),
-    buildCharacterPsychologyMethod(),
-    buildSupportingCharacterMethod(),
-    buildReaderPsychologyMethod(),
-    buildEmotionalPacingMethod(),
-    buildImmersionTechniques(),
-    buildGoldenChaptersRules(chapterNumber),
-    bookRules?.enableFullCastTracking ? buildFullCastTracking() : "",
-    buildGenreRules(genreProfile, genreBody),
-    buildProtagonistRules(bookRules),
-    buildBookRulesBody(bookRulesBody),
-    buildStyleGuide(styleGuide),
-    buildStyleFingerprint(styleFingerprint),
-    buildPreWriteChecklist(book, genreProfile),
-    outputSection,
-  ];
+  const sections = isEnglish
+    ? [
+        buildEnglishGenreIntro(book, genreProfile),
+        buildEnglishCoreRules(book),
+        buildEnglishAntiAIRules(),
+        buildEnglishCharacterMethod(),
+        buildGenreRules(genreProfile, genreBody),
+        buildProtagonistRules(bookRules),
+        buildBookRulesBody(bookRulesBody),
+        buildStyleGuide(styleGuide),
+        buildStyleFingerprint(styleFingerprint),
+        fanficContext ? buildFanficCanonSection(fanficContext.fanficCanon, fanficContext.fanficMode) : "",
+        fanficContext ? buildCharacterVoiceProfiles(fanficContext.fanficCanon) : "",
+        fanficContext ? buildFanficModeInstructions(fanficContext.fanficMode, fanficContext.allowedDeviations) : "",
+        buildEnglishPreWriteChecklist(book, genreProfile),
+        outputSection,
+      ]
+    : [
+        buildGenreIntro(book, genreProfile),
+        buildCoreRules(book),
+        buildAntiAIExamples(),
+        buildCharacterPsychologyMethod(),
+        buildSupportingCharacterMethod(),
+        buildReaderPsychologyMethod(),
+        buildEmotionalPacingMethod(),
+        buildImmersionTechniques(),
+        buildGoldenChaptersRules(chapterNumber),
+        bookRules?.enableFullCastTracking ? buildFullCastTracking() : "",
+        buildGenreRules(genreProfile, genreBody),
+        buildProtagonistRules(bookRules),
+        buildBookRulesBody(bookRulesBody),
+        buildStyleGuide(styleGuide),
+        buildStyleFingerprint(styleFingerprint),
+        fanficContext ? buildFanficCanonSection(fanficContext.fanficCanon, fanficContext.fanficMode) : "",
+        fanficContext ? buildCharacterVoiceProfiles(fanficContext.fanficCanon) : "",
+        fanficContext ? buildFanficModeInstructions(fanficContext.fanficMode, fanficContext.allowedDeviations) : "",
+        buildPreWriteChecklist(book, genreProfile),
+        outputSection,
+      ];
 
   return sections.filter(Boolean).join("\n\n");
 }

+ 47 - 3
packages/core/src/agents/writer.ts

@@ -2,7 +2,7 @@ import { BaseAgent } from "./base.js";
 import type { BookConfig } from "../models/book.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import type { BookRules } from "../models/book-rules.js";
-import { buildWriterSystemPrompt } from "./writer-prompts.js";
+import { buildWriterSystemPrompt, type FanficContext } from "./writer-prompts.js";
 import { buildSettlerSystemPrompt, buildSettlerUserPrompt } from "./settler-prompts.js";
 import { parseSettlementOutput } from "./settler-parser.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
@@ -57,7 +57,7 @@ export class WriterAgent extends BaseAgent {
     const [
       storyBible, volumeOutline, styleGuide, currentState, ledger, hooks,
       chapterSummaries, subplotBoard, emotionalArcs, characterMatrix, styleProfileRaw,
-      parentCanon,
+      parentCanon, fanficCanonRaw,
     ] = await Promise.all([
         this.readFileOrDefault(join(bookDir, "story/story_bible.md")),
         this.readFileOrDefault(join(bookDir, "story/volume_outline.md")),
@@ -71,6 +71,7 @@ export class WriterAgent extends BaseAgent {
         this.readFileOrDefault(join(bookDir, "story/character_matrix.md")),
         this.readFileOrDefault(join(bookDir, "story/style_profile.json")),
         this.readFileOrDefault(join(bookDir, "story/parent_canon.md")),
+        this.readFileOrDefault(join(bookDir, "story/fanfic_canon.md")),
       ]);
 
     const recentChapters = await this.loadRecentChapters(bookDir, chapterNumber);
@@ -88,11 +89,22 @@ export class WriterAgent extends BaseAgent {
     const relevantSummaries = this.findRelevantSummaries(chapterSummaries, volumeOutline, chapterNumber);
 
     const hasParentCanon = parentCanon !== "(文件尚未创建)";
+    const hasFanficCanon = fanficCanonRaw !== "(文件尚未创建)";
+
+    // Build fanfic context if fanfic_canon.md exists
+    const fanficContext: FanficContext | undefined = hasFanficCanon && bookRules?.fanficMode
+      ? {
+          fanficCanon: fanficCanonRaw,
+          fanficMode: bookRules.fanficMode,
+          allowedDeviations: bookRules.allowedDeviations ?? [],
+        }
+      : undefined;
 
     // ── Phase 1: Creative writing (temperature 0.7) ──
+    const resolvedLanguage = book.language ?? genreProfile.language;
     const creativeSystemPrompt = buildWriterSystemPrompt(
       book, genreProfile, bookRules, bookRulesBody, genreBody, styleGuide, styleFingerprint,
-      chapterNumber, "creative",
+      chapterNumber, "creative", fanficContext, resolvedLanguage,
     );
 
     const creativeUserPrompt = this.buildUserPrompt({
@@ -112,6 +124,7 @@ export class WriterAgent extends BaseAgent {
       dialogueFingerprints,
       relevantSummaries,
       parentCanon: hasParentCanon ? parentCanon : undefined,
+      language: book.language ?? genreProfile.language,
     });
 
     const creativeTemperature = input.temperatureOverride ?? 0.7;
@@ -307,6 +320,7 @@ export class WriterAgent extends BaseAgent {
     readonly dialogueFingerprints?: string;
     readonly relevantSummaries?: string;
     readonly parentCanon?: string;
+    readonly language?: "zh" | "en";
   }): string {
     const contextBlock = params.externalContext
       ? `\n## 外部指令\n以下是来自外部系统的创作指令,请在本章中融入:\n\n${params.externalContext}\n`
@@ -346,6 +360,36 @@ export class WriterAgent extends BaseAgent {
 ${params.parentCanon}\n`
       : "";
 
+    if (params.language === "en") {
+      return `Write chapter ${params.chapterNumber}.
+${contextBlock}
+## Current State
+${params.currentState}
+${ledgerBlock}
+## Plot Threads
+${params.hooks}
+${summariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}${fingerprintBlock}${relevantBlock}${canonBlock}
+## Recent Chapters
+${params.recentChapters || "(This is the first chapter, no previous text)"}
+
+## Worldbuilding
+${params.storyBible}
+
+## Volume Outline (Hard Constraint — Must Follow)
+${params.volumeOutline}
+
+[Outline Rules]
+- This chapter must advance the plot points assigned to it in the volume outline. Do not skip ahead or consume future plot points.
+- If the outline specifies an event for chapter N, do not resolve it early.
+- Pacing must match the outline's chapter span: if 5 chapters are planned for an arc, do not compress into 1-2.
+- PRE_WRITE_CHECK must identify which outline node this chapter covers.
+
+Requirements:
+- Chapter body must be at least ${params.wordCount} words
+- Output PRE_WRITE_CHECK first, then the chapter
+- Output only PRE_WRITE_CHECK, CHAPTER_TITLE, and CHAPTER_CONTENT blocks`;
+    }
+
     return `请续写第${params.chapterNumber}章。
 ${contextBlock}
 ## 当前状态卡

+ 6 - 1
packages/core/src/index.ts

@@ -1,5 +1,5 @@
 // Models
-export { type BookConfig, type Platform, type Genre, type BookStatus, BookConfigSchema, PlatformSchema, GenreSchema, BookStatusSchema } from "./models/book.js";
+export { type BookConfig, type Platform, type Genre, type BookStatus, type FanficMode, BookConfigSchema, PlatformSchema, GenreSchema, BookStatusSchema, FanficModeSchema } from "./models/book.js";
 export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusSchema } from "./models/chapter.js";
 export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, type AgentLLMOverride, ProjectConfigSchema, LLMConfigSchema, AgentLLMOverrideSchema, DetectionConfigSchema, QualityGatesSchema } from "./models/project.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";
@@ -31,11 +31,16 @@ export { ChapterAnalyzerAgent, type AnalyzeChapterInput, type AnalyzeChapterOutp
 export { parseWriterOutput, parseCreativeOutput, type ParsedWriterOutput, type CreativeOutput } from "./agents/writer-parser.js";
 export { buildSettlerSystemPrompt, buildSettlerUserPrompt } from "./agents/settler-prompts.js";
 export { parseSettlementOutput, type SettlementOutput } from "./agents/settler-parser.js";
+export { FanficCanonImporter, type FanficCanonOutput } from "./agents/fanfic-canon-importer.js";
+export { getFanficDimensionConfig, FANFIC_DIMENSIONS, type FanficDimensionConfig } from "./agents/fanfic-dimensions.js";
+export { buildFanficCanonSection, buildCharacterVoiceProfiles, buildFanficModeInstructions } from "./agents/fanfic-prompt-sections.js";
 
 // Utils
 export { fetchUrl } from "./utils/web-search.js";
 export { splitChapters, type SplitChapter } from "./utils/chapter-splitter.js";
 export { createLogger, createStderrSink, createJsonLineSink, nullSink, type Logger, type LogSink, type LogLevel, type LogEntry } from "./utils/logger.js";
+export { loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH } from "./utils/config-loader.js";
+export { computeAnalytics, type AnalyticsData, type TokenStats } from "./utils/analytics.js";
 
 // Pipeline
 export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type ReviseResult, type TruthFiles, type BookStatusInfo, type ImportChaptersInput, type ImportChaptersResult, type TokenUsageSummary } from "./pipeline/runner.js";

+ 2 - 0
packages/core/src/models/book-rules.ts

@@ -34,6 +34,8 @@ export const BookRulesSchema = z.object({
   fatigueWordsOverride: z.array(z.string()).default([]),
   additionalAuditDimensions: z.array(z.union([z.number(), z.string()])).default([]),
   enableFullCastTracking: z.boolean().default(false),
+  fanficMode: z.enum(["canon", "au", "ooc", "cp"]).optional(),
+  allowedDeviations: z.array(z.string()).default([]),
 });
 
 export type BookRules = z.infer<typeof BookRulesSchema>;

+ 7 - 7
packages/core/src/models/book.ts

@@ -3,13 +3,7 @@ import { z } from "zod";
 export const PlatformSchema = z.enum(["tomato", "feilu", "qidian", "other"]);
 export type Platform = z.infer<typeof PlatformSchema>;
 
-export const GenreSchema = z.enum([
-  "xuanhuan",
-  "xianxia",
-  "urban",
-  "horror",
-  "other",
-]);
+export const GenreSchema = z.string().min(1);
 export type Genre = z.infer<typeof GenreSchema>;
 
 export const BookStatusSchema = z.enum([
@@ -22,6 +16,9 @@ export const BookStatusSchema = z.enum([
 ]);
 export type BookStatus = z.infer<typeof BookStatusSchema>;
 
+export const FanficModeSchema = z.enum(["canon", "au", "ooc", "cp"]);
+export type FanficMode = z.infer<typeof FanficModeSchema>;
+
 export const BookConfigSchema = z.object({
   id: z.string().min(1),
   title: z.string().min(1),
@@ -30,8 +27,11 @@ export const BookConfigSchema = z.object({
   status: BookStatusSchema,
   targetChapters: z.number().int().min(1).default(200),
   chapterWordCount: z.number().int().min(1000).default(3000),
+  language: z.enum(["zh", "en"]).optional(),
   createdAt: z.string().datetime(),
   updatedAt: z.string().datetime(),
+  parentBookId: z.string().optional(),
+  fanficMode: FanficModeSchema.optional(),
 });
 
 export type BookConfig = z.infer<typeof BookConfigSchema>;

+ 1 - 0
packages/core/src/models/genre-profile.ts

@@ -4,6 +4,7 @@ import yaml from "js-yaml";
 export const GenreProfileSchema = z.object({
   name: z.string(),
   id: z.string(),
+  language: z.enum(["zh", "en"]).default("zh"),
   chapterTypes: z.array(z.string()),
   fatigueWords: z.array(z.string()),
   numericalSystem: z.boolean().default(false),

+ 1 - 0
packages/core/src/models/project.ts

@@ -73,6 +73,7 @@ const ModelOverrideValueSchema = z.union([z.string(), AgentLLMOverrideSchema]);
 export const ProjectConfigSchema = z.object({
   name: z.string().min(1),
   version: z.literal("0.1.0"),
+  language: z.enum(["zh", "en"]).default("zh"),
   llm: LLMConfigSchema,
   notify: z.array(NotifyChannelSchema).default([]),
   detection: DetectionConfigSchema.optional(),

+ 46 - 1
packages/core/src/pipeline/runner.ts

@@ -1,7 +1,7 @@
 import type { LLMClient, OnStreamProgress } from "../llm/provider.js";
 import { chatCompletion, createLLMClient } from "../llm/provider.js";
 import type { Logger } from "../utils/logger.js";
-import type { BookConfig } from "../models/book.js";
+import type { BookConfig, FanficMode } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
 import type { NotifyChannel, LLMConfig, AgentLLMOverride } from "../models/project.js";
 import type { GenreProfile } from "../models/genre-profile.js";
@@ -202,6 +202,51 @@ export class PipelineRunner {
     await this.state.snapshotState(book.id, 0);
   }
 
+  /** Import external source material and generate fanfic_canon.md */
+  async importFanficCanon(
+    bookId: string,
+    sourceText: string,
+    sourceName: string,
+    fanficMode: FanficMode,
+  ): Promise<string> {
+    const { FanficCanonImporter } = await import("../agents/fanfic-canon-importer.js");
+    const importer = new FanficCanonImporter(this.agentCtxFor("fanfic-canon-importer", bookId));
+    const result = await importer.importFromText(sourceText, sourceName, fanficMode);
+
+    const bookDir = this.state.bookDir(bookId);
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+    await writeFile(join(storyDir, "fanfic_canon.md"), result.fullDocument, "utf-8");
+
+    return result.fullDocument;
+  }
+
+  /** One-step fanfic book creation: create book + import canon + generate foundation */
+  async initFanficBook(
+    book: BookConfig,
+    sourceText: string,
+    sourceName: string,
+    fanficMode: FanficMode,
+  ): Promise<void> {
+    const bookDir = this.state.bookDir(book.id);
+
+    await this.state.saveBookConfig(book.id, book);
+
+    // Step 1: Import source material → fanfic_canon.md
+    const fanficCanon = await this.importFanficCanon(book.id, sourceText, sourceName, fanficMode);
+
+    // Step 2: Generate foundation from fanfic canon (not from scratch)
+    const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
+    const { profile: gp } = await this.loadGenreProfile(book.genre);
+    const foundation = await architect.generateFanficFoundation(book, fanficCanon, fanficMode);
+    await architect.writeFoundationFiles(bookDir, foundation, gp.numericalSystem);
+
+    // Step 3: Initialize chapters directory + snapshot
+    await mkdir(join(bookDir, "chapters"), { recursive: true });
+    await this.state.saveChapterIndex(book.id, []);
+    await this.state.snapshotState(book.id, 0);
+  }
+
   /** Write a single draft chapter. Saves chapter file + truth files + index + snapshot. */
   async writeDraft(bookId: string, context?: string, wordCount?: number): Promise<DraftResult> {
     const releaseLock = await this.state.acquireBookLock(bookId);

+ 92 - 0
packages/core/src/utils/analytics.ts

@@ -0,0 +1,92 @@
+export interface TokenStats {
+  readonly totalPromptTokens: number;
+  readonly totalCompletionTokens: number;
+  readonly totalTokens: number;
+  readonly avgTokensPerChapter: number;
+  readonly recentTrend: ReadonlyArray<{ readonly chapter: number; readonly totalTokens: number }>;
+}
+
+export interface AnalyticsData {
+  readonly bookId: string;
+  readonly totalChapters: number;
+  readonly totalWords: number;
+  readonly avgWordsPerChapter: number;
+  readonly auditPassRate: number;
+  readonly topIssueCategories: ReadonlyArray<{ readonly category: string; readonly count: number }>;
+  readonly chaptersWithMostIssues: ReadonlyArray<{ readonly chapter: number; readonly issueCount: number }>;
+  readonly statusDistribution: Record<string, number>;
+  readonly tokenStats?: TokenStats;
+}
+
+export function computeAnalytics(
+  bookId: string,
+  chapters: ReadonlyArray<{
+    readonly number: number;
+    readonly status: string;
+    readonly wordCount: number;
+    readonly auditIssues: ReadonlyArray<string>;
+    readonly tokenUsage?: {
+      readonly promptTokens: number;
+      readonly completionTokens: number;
+      readonly totalTokens: number;
+    };
+  }>,
+): AnalyticsData {
+  const totalChapters = chapters.length;
+  const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
+  const avgWordsPerChapter = totalChapters > 0 ? Math.round(totalWords / totalChapters) : 0;
+
+  const passedStatuses = new Set(["ready-for-review", "approved", "published"]);
+  const auditedChapters = chapters.filter(
+    (ch) => ch.status !== "drafted" && ch.status !== "drafting" && ch.status !== "card-generated",
+  );
+  const passedChapters = auditedChapters.filter((ch) => passedStatuses.has(ch.status));
+  const auditPassRate = auditedChapters.length > 0
+    ? Math.round((passedChapters.length / auditedChapters.length) * 100)
+    : 100;
+
+  const categoryCounts = new Map<string, number>();
+  for (const ch of chapters) {
+    for (const issue of ch.auditIssues) {
+      const catMatch = issue.match(/\[(?:critical|warning|info)\]\s*(.+?)[::]/);
+      const category = catMatch?.[1] ?? "未分类";
+      categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
+    }
+  }
+  const topIssueCategories = [...categoryCounts.entries()]
+    .sort((a, b) => b[1] - a[1])
+    .slice(0, 10)
+    .map(([category, count]) => ({ category, count }));
+
+  const chaptersWithMostIssues = [...chapters]
+    .filter((ch) => ch.auditIssues.length > 0)
+    .sort((a, b) => b.auditIssues.length - a.auditIssues.length)
+    .slice(0, 5)
+    .map((ch) => ({ chapter: ch.number, issueCount: ch.auditIssues.length }));
+
+  const statusDistribution: Record<string, number> = {};
+  for (const ch of chapters) {
+    statusDistribution[ch.status] = (statusDistribution[ch.status] ?? 0) + 1;
+  }
+
+  const chaptersWithUsage = chapters.filter((ch) => ch.tokenUsage);
+  let tokenStats: TokenStats | undefined;
+  if (chaptersWithUsage.length > 0) {
+    const totalPromptTokens = chaptersWithUsage.reduce((sum, ch) => sum + (ch.tokenUsage?.promptTokens ?? 0), 0);
+    const totalCompletionTokens = chaptersWithUsage.reduce((sum, ch) => sum + (ch.tokenUsage?.completionTokens ?? 0), 0);
+    const totalTokens = chaptersWithUsage.reduce((sum, ch) => sum + (ch.tokenUsage?.totalTokens ?? 0), 0);
+    const avgTokensPerChapter = Math.round(totalTokens / chaptersWithUsage.length);
+
+    const recentTrend = [...chaptersWithUsage]
+      .sort((a, b) => a.number - b.number)
+      .slice(-5)
+      .map((ch) => ({ chapter: ch.number, totalTokens: ch.tokenUsage?.totalTokens ?? 0 }));
+
+    tokenStats = { totalPromptTokens, totalCompletionTokens, totalTokens, avgTokensPerChapter, recentTrend };
+  }
+
+  return {
+    bookId, totalChapters, totalWords, avgWordsPerChapter, auditPassRate,
+    topIssueCategories, chaptersWithMostIssues, statusDistribution, tokenStats,
+  };
+}

+ 63 - 0
packages/core/src/utils/config-loader.ts

@@ -0,0 +1,63 @@
+import { readFile, access } from "node:fs/promises";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { ProjectConfigSchema, type ProjectConfig } from "../models/project.js";
+
+export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
+export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
+
+/**
+ * Load project config from inkos.json with .env overrides.
+ * Shared by CLI and Studio — single source of truth for config loading.
+ */
+export async function loadProjectConfig(root: string): Promise<ProjectConfig> {
+  // Load global ~/.inkos/.env first, then project .env overrides
+  const { config: loadEnv } = await import("dotenv");
+  loadEnv({ path: GLOBAL_ENV_PATH });
+  loadEnv({ path: join(root, ".env"), override: true });
+
+  const configPath = join(root, "inkos.json");
+
+  try {
+    await access(configPath);
+  } catch {
+    throw new Error(
+      `inkos.json not found in ${root}.\nMake sure you are inside an InkOS project directory (cd into the project created by 'inkos init').`,
+    );
+  }
+
+  const raw = await readFile(configPath, "utf-8");
+
+  let config: Record<string, unknown>;
+  try {
+    config = JSON.parse(raw);
+  } catch {
+    throw new Error(`inkos.json in ${root} is not valid JSON. Check the file for syntax errors.`);
+  }
+
+  // .env overrides inkos.json for LLM settings
+  const env = process.env;
+  const llm = (config.llm ?? {}) as Record<string, unknown>;
+  if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER;
+  if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL;
+  if (env.INKOS_LLM_MODEL) llm.model = env.INKOS_LLM_MODEL;
+  if (env.INKOS_LLM_TEMPERATURE) llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE);
+  if (env.INKOS_LLM_MAX_TOKENS) llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10);
+  if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
+  if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
+  config.llm = llm;
+
+  // Global language override
+  if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;
+
+  // API key ONLY from env — never stored in inkos.json
+  const apiKey = env.INKOS_LLM_API_KEY;
+  if (!apiKey) {
+    throw new Error(
+      "INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.",
+    );
+  }
+  llm.apiKey = apiKey;
+
+  return ProjectConfigSchema.parse(config);
+}