Initial commit — Jianghu Chronicles: Road of the Wandering Blade

Original open-world wuxia browser RPG:
- Graphical 48px tile world (12 locations) with walkable character,
  NPC interaction, action spots, roaming enemies, travel portals
- Tactical 10x7 grid battles: shapes, statuses, internals, 30+ techniques
  incl. tier-V ultimates & support arts across 6 weapon types
- Full equipment: weapon/head/body/feet/2 accessories, 5 tiers, smithing
- 6-chapter main story + side jobs board + bounty hunts + tournament
- Crafting (smith/alchemy), fishing minigame, gambling, pickpocketing
- Companions with chemistry passives, affection, romance, sects
- Achievements, monster codex, day/night cycle, endings
- 25-test headless smoke suite; no-cache static server included
This commit is contained in:
2026-08-23 07:01:08 +00:00
commit 8680f79102
14 changed files with 6244 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
*.log
.DS_Store
+263
View File
@@ -0,0 +1,263 @@
/* ============================================================
Jianghu Chronicles — ink-wash dark UI
============================================================ */
:root{
--bg:#14120e; --bg2:#1c1913; --panel:#221e16; --panel2:#2a251b;
--line:#4a4232; --line2:#5f5540;
--ink:#e8dfc8; --ink-dim:#a99e83; --ink-faint:#6f6752;
--gold:#d8b36a; --gold-bright:#f0cd85;
--red:#c05a4e; --red-deep:#8f3a32;
--green:#7ba05b; --jade:#6fa88f;
--blue:#6f93b8;
--danger:#d06557; --ok:#9dbb74;
--shadow:0 10px 30px rgba(0,0,0,.55);
--radius:10px;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
background:
radial-gradient(1200px 600px at 70% -10%, rgba(216,179,106,.07), transparent 60%),
radial-gradient(900px 500px at -10% 110%, rgba(111,147,184,.05), transparent 60%),
var(--bg);
color:var(--ink);
font-family:"Noto Serif SC","Source Han Serif SC",Georgia,"Times New Roman",serif;
font-size:15px; line-height:1.5;
}
#app{min-height:100vh;display:flex;flex-direction:column}
h1,h2,h3{font-weight:600;letter-spacing:.03em}
button{font-family:inherit;color:inherit;background:none;border:none;cursor:pointer}
input,select,textarea{font-family:inherit;font-size:inherit;color:var(--ink);background:var(--bg2);border:1px solid var(--line);border-radius:6px;padding:6px 10px}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--gold)}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:var(--line2);border-radius:6px}
::-webkit-scrollbar-track{background:transparent}
/* ---------- generic ---------- */
.btn{
display:inline-block;padding:8px 18px;border:1px solid var(--line2);border-radius:8px;
background:linear-gradient(180deg,var(--panel2),var(--panel));
color:var(--ink);letter-spacing:.04em;transition:all .15s ease;user-select:none;
}
.btn:hover:not(:disabled){border-color:var(--gold);color:var(--gold-bright);transform:translateY(-1px)}
.btn:active:not(:disabled){transform:translateY(0)}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn.primary{border-color:var(--gold);color:#1a160e;background:linear-gradient(180deg,var(--gold-bright),var(--gold));font-weight:600}
.btn.primary:hover:not(:disabled){filter:brightness(1.08);color:#1a160e}
.btn.danger{border-color:var(--red-deep);color:var(--red)}
.btn.small{padding:4px 10px;font-size:13px;border-radius:6px}
.btn.wide{display:block;width:100%;text-align:center;margin-top:6px}
.tag{display:inline-block;padding:1px 8px;border:1px solid var(--line);border-radius:20px;font-size:12px;color:var(--ink-dim);margin-right:4px}
.tag.gold{border-color:var(--gold);color:var(--gold)}
.tag.red{border-color:var(--red-deep);color:var(--red)}
.tag.green{border-color:var(--green);color:var(--green)}
.tag.blue{border-color:var(--blue);color:var(--blue)}
.hr{height:1px;background:linear-gradient(90deg,transparent,var(--line),transparent);margin:12px 0}
.muted{color:var(--ink-dim)} .faint{color:var(--ink-faint)} .goldtx{color:var(--gold)} .redtx{color:var(--danger)} .greentx{color:var(--ok)} .bluetx{color:var(--blue)}
.small{font-size:13px} .big{font-size:19px}
.row{display:flex;gap:10px;align-items:center} .col{display:flex;flex-direction:column;gap:10px}
.spread{justify-content:space-between}
.wrap{flex-wrap:wrap}
.grow{flex:1}
.card{background:linear-gradient(180deg,var(--panel2),var(--panel));border:1px solid var(--line);border-radius:var(--radius);padding:14px}
.clickable{cursor:pointer;transition:border-color .15s, transform .15s}
.clickable:hover{border-color:var(--gold)}
/* ---------- screens ---------- */
.screen{flex:1;display:flex;flex-direction:column;animation:fadeIn .35s ease}
.screen[hidden]{display:none!important}
/* ---- 2D world ---- */
.w2d-wrap{position:relative;margin-bottom:12px}
#w2d{width:100%;max-width:960px;display:block;border:1px solid var(--line);border-radius:10px;background:#101820;box-shadow:0 6px 24px rgba(0,0,0,.45);image-rendering:pixelated}
.w2d-hint{margin-top:6px;font-size:12.5px;color:var(--muted)}
.dpad{position:absolute;right:12px;bottom:12px;display:grid;grid-template-columns:repeat(3,46px);grid-template-rows:repeat(3,46px);gap:6px;opacity:.9;user-select:none;-webkit-user-select:none}
.dpad-btn{grid-column:var(--c);grid-row:var(--r);border:1px solid rgba(216,179,106,.55);border-radius:9px;background:rgba(12,16,24,.72);color:#e8dcc2;font-size:17px;line-height:1;cursor:pointer;touch-action:none;display:flex;align-items:center;justify-content:center}
.dpad-btn.on{background:rgba(216,179,106,.35);border-color:var(--gold-bright)}
.dpad .up{--c:2;--r:1}.dpad .left{--c:1;--r:2}.dpad .e{--c:2;--r:2;font-weight:bold;color:var(--gold-bright)}
.dpad .right{--c:3;--r:2}.dpad .down{--c:2;--r:3}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
#screen-title{align-items:center;justify-content:center;text-align:center;padding:30px}
.title-inner{max-width:720px;width:100%}
.game-logo{font-size:64px;color:var(--gold);text-shadow:0 0 40px rgba(216,179,106,.25);margin-bottom:4px}
.game-logo span{display:block;font-size:26px;letter-spacing:.28em;color:var(--ink);margin-top:6px;text-transform:uppercase}
.tagline{color:var(--ink-dim);margin-bottom:34px;font-style:italic}
.title-menu{display:flex;flex-direction:column;gap:10px;max-width:340px;margin:0 auto 40px}
.title-menu .btn{padding:12px}
.fineprint{font-size:12px;color:var(--ink-faint)}
/* ---------- creation ---------- */
#screen-create{padding:24px;overflow-y:auto}
.create-wrap{max-width:980px;margin:0 auto;display:flex;flex-direction:column;gap:16px}
.origin-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
.origin-card{padding:14px;border-radius:var(--radius);border:1px solid var(--line);background:var(--panel);cursor:pointer;transition:all .15s}
.origin-card:hover{border-color:var(--line2)}
.origin-card.sel{border-color:var(--gold);box-shadow:0 0 0 1px var(--gold) inset;background:linear-gradient(180deg,#2b2416,var(--panel))}
.attr-row{display:flex;align-items:center;gap:12px;padding:6px 0}
.attr-row .attr-name{width:190px}
.pip{width:16px;height:16px;border:1px solid var(--line2);border-radius:4px;display:inline-block;margin-right:3px;vertical-align:middle}
.pip.on{background:var(--gold);border-color:var(--gold)}
.stepper{display:flex;gap:6px;align-items:center}
.stepper button{width:28px;height:28px;border:1px solid var(--line2);border-radius:6px;background:var(--panel2)}
.stepper button:hover:not(:disabled){border-color:var(--gold)}
.stepper .val{width:34px;text-align:center;font-size:17px}
/* ---------- world layout ---------- */
#screen-world{max-width:1280px;width:100%;margin:0 auto;padding:0 14px;height:100vh}
#topbar{
display:flex;align-items:center;gap:18px;flex-wrap:wrap;
padding:10px 16px;margin-top:10px;
background:linear-gradient(180deg,var(--panel2),var(--panel));
border:1px solid var(--line);border-radius:var(--radius);
}
.top-stat{display:flex;align-items:center;gap:6px;white-space:nowrap}
.top-stat b{color:var(--gold-bright);font-weight:600}
.hpbar{width:130px;height:10px;background:#000a;border:1px solid var(--line);border-radius:6px;overflow:hidden}
.hpbar i{display:block;height:100%;background:linear-gradient(90deg,#a33c31,var(--danger));transition:width .3s}
.mpbar{width:110px;height:10px;background:#000a;border:1px solid var(--line);border-radius:6px;overflow:hidden}
.mpbar i{display:block;height:100%;background:linear-gradient(90deg,#3a6ea5,var(--blue));transition:width .3s}
.xpbar{width:90px;height:8px;background:#000a;border:1px solid var(--line);border-radius:6px;overflow:hidden}
.xpbar i{display:block;height:100%;background:var(--gold);transition:width .3s}
#tabs{display:flex;gap:4px;margin:10px 0 0;flex-wrap:wrap}
#tabs button{
padding:8px 20px;border:1px solid var(--line);border-bottom:none;
border-radius:10px 10px 0 0;background:var(--panel);color:var(--ink-dim);
}
#tabs button.active{background:var(--panel2);color:var(--gold-bright);border-color:var(--line2);box-shadow:0 -3px 8px rgba(0,0,0,.3) inset}
#tabview{
flex:1;overflow-y:auto;padding:18px;
border:1px solid var(--line);border-radius:0 var(--radius) var(--radius) var(--radius);
background:linear-gradient(180deg,rgba(42,37,27,.65),rgba(34,30,22,.9));
min-height:300px;
}
#log{
height:96px;overflow-y:auto;margin:8px 0 12px;padding:8px 14px;
border:1px solid var(--line);border-radius:var(--radius);
background:rgba(0,0,0,.35);font-size:13.5px;
}
#log p{padding:1px 0;color:var(--ink-dim)}
#log p.good{color:var(--ok)} #log p.bad{color:var(--danger)} #log p.sys{color:var(--gold)} #log p.combat{color:var(--blue)}
/* ---------- scene ---------- */
.scene-head h2{font-size:24px;color:var(--gold-bright)}
.scene-desc{color:var(--ink-dim);font-style:italic;margin:4px 0 14px;max-width:900px}
.scene-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
.npc-card{display:flex;gap:12px;align-items:center;padding:12px}
.portrait{
width:52px;height:52px;border-radius:50%;flex:none;
display:flex;align-items:center;justify-content:center;
font-size:22px;font-weight:700;color:#141210;
border:2px solid var(--line2);
}
.npc-info{flex:1;min-width:0}
.npc-name{font-weight:600}
.npc-role{font-size:12.5px;color:var(--ink-faint)}
.aff-pips{font-size:11px;letter-spacing:2px;color:var(--gold)}
.facil-list{display:flex;flex-direction:column;gap:8px;margin-top:14px}
.facil-btn{text-align:left;padding:10px 16px;display:flex;justify-content:space-between;align-items:center}
.facil-btn small{color:var(--ink-faint)}
/* ---------- map ---------- */
.map-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px}
.map-card{position:relative;padding:14px}
.map-card.current{border-color:var(--gold);box-shadow:0 0 14px rgba(216,179,106,.15)}
.map-card.unknown{opacity:.45;filter:grayscale(.6)}
.map-card h3{color:var(--gold-bright);margin-bottom:4px}
.danger-stars{color:var(--red);letter-spacing:2px;font-size:12px}
.travel-note{font-size:12px;color:var(--ink-faint);margin-top:6px}
/* ---------- tables / lists ---------- */
table.list{width:100%;border-collapse:collapse}
table.list th{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint);text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)}
table.list td{padding:7px 8px;border-bottom:1px solid rgba(74,66,50,.4);vertical-align:middle}
table.list tr:hover td{background:rgba(216,179,106,.05)}
.rar-0{color:var(--ink-dim)} .rar-1{color:var(--green)} .rar-2{color:var(--blue)} .rar-3{color:var(--gold)} .rar-4{color:#d78ab0}
/* ---------- party / character ---------- */
.stat-table{width:100%}
.stat-table td{padding:4px 6px}
.equip-slot{display:flex;justify-content:space-between;align-items:center;padding:7px 10px;border:1px dashed var(--line);border-radius:8px;margin-bottom:6px;background:rgba(0,0,0,.2)}
.inv-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:8px}
.item-chip{display:flex;justify-content:space-between;gap:8px;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:rgba(0,0,0,.18);align-items:center}
.item-chip .qty{color:var(--ink-faint);font-size:12px}
.skill-block{border:1px solid var(--line);border-radius:var(--radius);padding:12px;margin-bottom:12px;background:rgba(0,0,0,.15)}
.profbar{height:8px;background:#000a;border-radius:5px;border:1px solid var(--line);overflow:hidden;width:140px;display:inline-block;vertical-align:middle}
.profbar i{display:block;height:100%;background:linear-gradient(90deg,#8a6d2f,var(--gold))}
.move-row{display:flex;gap:10px;padding:5px 0;border-bottom:1px dotted rgba(74,66,50,.5);font-size:13.5px;align-items:baseline}
.move-row .mv-name{min-width:150px;color:var(--ink)}
.move-row.locked{opacity:.38}
/* ---------- dialogue ---------- */
.dlg-backdrop{position:absolute;inset:0;background:rgba(8,7,4,.82);display:flex;align-items:flex-end;justify-content:center;z-index:60;backdrop-filter:blur(2px)}
.dlg-box{
width:min(860px,94vw);margin-bottom:6vh;border:1px solid var(--line2);border-radius:var(--radius);
background:linear-gradient(180deg,#262115,#1c1913);box-shadow:var(--shadow);
padding:20px 24px;animation:slideUp .25s ease;
}
@keyframes slideUp{from{transform:translateY(24px);opacity:0}to{transform:none;opacity:1}}
.dlg-speaker{color:var(--gold-bright);font-size:17px;font-weight:600;margin-bottom:8px;display:flex;align-items:center;gap:10px}
.dlg-text{min-height:64px;color:var(--ink);white-space:pre-wrap}
.dlg-opts{display:flex;flex-direction:column;gap:8px;margin-top:16px}
.dlg-opt{text-align:left;padding:9px 14px;border:1px solid var(--line);border-radius:8px;background:rgba(255,255,255,.02)}
.dlg-opt:hover{border-color:var(--gold);color:var(--gold-bright)}
/* ---------- modal & toast ---------- */
.modal-backdrop{position:absolute;inset:0;background:rgba(8,7,4,.78);display:flex;align-items:center;justify-content:center;z-index:70;backdrop-filter:blur(2px)}
.modal{
width:min(760px,94vw);max-height:86vh;overflow-y:auto;
background:linear-gradient(180deg,var(--panel2),var(--panel));
border:1px solid var(--line2);border-radius:var(--radius);box-shadow:var(--shadow);
padding:22px 24px;animation:popIn .2s ease;
}
@keyframes popIn{from{transform:scale(.96);opacity:0}to{transform:none;opacity:1}}
.modal h2{color:var(--gold-bright);margin-bottom:12px}
.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:18px}
#toasts{position:absolute;top:70px;left:50%;transform:translateX(-50%);z-index:90;display:flex;flex-direction:column;gap:8px;pointer-events:none}
.toast{
padding:10px 22px;border-radius:30px;border:1px solid var(--gold);color:var(--gold-bright);
background:rgba(20,18,14,.92);box-shadow:var(--shadow);animation:toastIn .25s ease;
font-size:14px;white-space:nowrap;
}
.toast.bad{border-color:var(--red);color:var(--red)}
@keyframes toastIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1}}
/* ---------- combat overlay ---------- */
.battle-wrap{position:absolute;inset:0;z-index:50;display:flex;flex-direction:column;align-items:center;justify-content:center;background:rgba(8,7,4,.9);backdrop-filter:blur(3px);animation:fadeIn .3s}
.battle-frame{width:min(1040px,97vw)}
.battle-head{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;color:var(--ink-dim)}
.battle-canvas-holder{position:relative;border:1px solid var(--line2);border-radius:var(--radius);overflow:hidden;background:#0d0c09;box-shadow:var(--shadow)}
#battle-canvas{display:block;width:100%;height:auto;cursor:crosshair}
.turn-banner{
position:absolute;top:38%;left:0;right:0;text-align:center;pointer-events:none;
font-size:34px;color:var(--gold-bright);text-shadow:0 2px 18px #000;letter-spacing:.2em;
opacity:0;transition:opacity .2s;
}
.turn-banner.show{opacity:1}
.battle-bar{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap;align-items:center}
.move-btn{min-width:120px;text-align:left;padding:7px 12px;line-height:1.3}
.move-btn small{display:block;color:var(--ink-faint);font-size:11.5px}
.move-btn.selected{border-color:var(--gold);color:var(--gold-bright);box-shadow:0 0 0 1px var(--gold) inset}
.move-btn:disabled{opacity:.35}
.unit-strip{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
.mini-unit{display:flex;gap:8px;align-items:center;border:1px solid var(--line);border-radius:8px;padding:5px 9px;background:rgba(0,0,0,.3);font-size:12.5px;min-width:150px}
.mini-unit.dead{opacity:.35}
.mini-unit.active{border-color:var(--gold)}
.mini-unit .mu-hp{width:70px}
.battle-log{height:76px;overflow-y:auto;margin-top:8px;padding:6px 12px;border:1px solid var(--line);border-radius:8px;background:rgba(0,0,0,.4);font-size:13px}
.battle-log p{color:var(--ink-dim)} .battle-log p.crit{color:var(--gold)} .battle-log p.heal{color:var(--ok)} .battle-log p.bad{color:var(--danger)}
/* floaters drawn on canvas */
/* ---------- ending ---------- */
.ending-wrap{max-width:700px;margin:40px auto;text-align:center}
.ending-title{font-size:44px;color:var(--gold-bright);margin-bottom:18px;letter-spacing:.12em}
.ending-body{color:var(--ink);text-align:left;white-space:pre-wrap;line-height:1.9}
/* ---------- responsive ---------- */
@media (max-width:760px){
.game-logo{font-size:42px}
#topbar{gap:10px}
.hpbar{width:90px}.mpbar{width:70px}
.move-row .mv-name{min-width:110px}
}
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jianghu Chronicles — Road of the Wandering Blade</title>
<link rel="stylesheet" href="css/style.css?v=10">
</head>
<body>
<div id="app">
<!-- ===== TITLE SCREEN ===== -->
<section id="screen-title" class="screen">
<div class="title-inner">
<h1 class="game-logo">江湖 <span>Jianghu Chronicles</span></h1>
<p class="tagline">Road of the Wandering Blade — an open-world wuxia RPG</p>
<div class="title-menu" id="title-menu"></div>
<p class="fineprint">An original browser homage to classic open-world martial-arts RPGs.<br>All names, characters and stories herein are original works of fiction.</p>
</div>
</section>
<!-- ===== CHARACTER CREATION ===== -->
<section id="screen-create" class="screen" hidden></section>
<!-- ===== WORLD SCREEN ===== -->
<section id="screen-world" class="screen" hidden>
<header id="topbar"></header>
<nav id="tabs"></nav>
<main id="tabview"></main>
<footer><div id="log"></div></footer>
</section>
<!-- ===== OVERLAYS ===== -->
<div id="overlay" hidden></div>
<canvas id="fx-layer" width="10" height="10" hidden></canvas>
</div>
<script src="js/data.items.js?v=10"></script>
<script src="js/data.world.js?v=10"></script>
<script src="js/data.dialogues.js?v=10"></script>
<script src="js/engine.js?v=10"></script>
<script src="js/combat.js?v=10"></script>
<script src="js/game.js?v=10"></script>
<script src="js/world2d.js?v=10"></script>
<script src="js/ui.js?v=10"></script>
<script src="js/main.js?v=10"></script>
</body>
</html>
+680
View File
@@ -0,0 +1,680 @@
'use strict';
/* ============================================================
COMBAT — grid tactics engine (10x7, Chebyshev distance)
============================================================ */
const GRID_W = 10, GRID_H = 7;
function makeUnit(o) {
return Object.assign({
uid: Util.uid(), name: '?', side: 'enemy', x: 0, y: 0,
hp: 50, hpMax: 50, mp: 30, mpMax: 30,
atk: 10, def: 3, spd: 8, crit: 4, dodge: 5,
mv: 4, rng: 1, wt: 'sword',
techs: [], internal: null, internalLv: 1, light: null,
sts: [], cds: {}, moved: false, acted: false,
alive: true, ai: 'brute', glyph: '敌', color: '#a05c46',
lvl: 1, coatPoisonTurns: 0, isPlayer: false, npcId: null
}, o);
}
/* Build the player's battle unit from persistent state */
function makePlayerUnit(p) {
const d = Stats.recalc(p);
const techs = Object.keys(p.techs);
const u = makeUnit({
name: p.name, side: 'ally', isPlayer: true,
hp: p.hp, hpMax: d.maxHp, mp: p.mp, mpMax: d.maxMp,
atk: d.atk, def: d.def, spd: d.spd, crit: d.crit, dodge: d.dodge,
mv: d.mv, rng: d.rng, wt: d.wt,
techs, internal: p.internal, internalLv: p.internalLv, light: p.light,
glyph: '侠', color: '#d8b36a', ai: 'player'
});
// companion chemistry passives (active battle party only)
if (G.party && G.party.includes('n_tiehu')) {
u.hpMax = Math.round(u.hpMax * 1.08); u.hp = Math.min(u.hp + Math.round(d.maxHp * 0.08), u.hpMax);
}
if (G.party && G.party.includes('n_lin')) u.crit += 4;
if (G.party && G.party.includes('n_suqing')) { u.dodge += 6; u.spd += 1; }
return u;
}
/* Build a companion unit from an NPC definition */
function makeCompanionUnit(npcId, plvl) {
const n = NPCS[npcId]; const c = n.companion;
if (!c) return null;
const lvl = Math.max(1, plvl);
const ia = DATA ? INTERNALS[c.internal] : null;
const hp = Math.round((70 + lvl * 16) * (c.hpM || 1));
const atk = Math.round((9 + lvl * 2.6) * (c.atkM || 1));
const def = Math.round((4 + lvl * 0.9) * (c.defM || 1));
const spd = 9 + Math.floor(lvl * 0.7) + (c.ai === 'support' ? 2 : 0);
const mpMax = 40 + lvl * 6;
return makeUnit({
name: n.name, side: 'ally', npcId,
hp: hp, hpMax: hp, mp: mpMax, mpMax,
atk, def, spd, crit: 6 + Math.floor(lvl / 4), dodge: 6 + Math.floor(lvl / 3),
mv: 4 + (c.ai === 'support' ? 2 : 1), rng: c.wt === 'hidden' ? 4 : (c.wt === 'spear' ? 2 : 1),
wt: c.wt, techs: [c.techs[0], 't_univ_1'].concat(c.techs.slice(1)),
internal: c.internal, internalLv: 1 + Math.floor(lvl / 6),
glyph: n.char, color: n.hue, ai: c.ai || 'brute'
});
}
/* Build an enemy unit; scale = level scaling factor */
function makeEnemyUnit(enemyId, scale) {
const e = ENEMIES[enemyId]; if (!e) return null;
scale = scale || 1;
const hp = Math.round(e.hp * scale), mp = 20 + e.tier * 12;
return makeUnit({
name: e.name, side: 'enemy',
hp, hpMax: hp, mp, mpMax: mp,
atk: Math.round(e.atk * scale), def: Math.round(e.def * (0.7 + 0.3 * scale)),
spd: e.spd, crit: 4 + e.tier * 2, dodge: 4 + e.tier * 2,
mv: e.mv, rng: e.rng || 1, wt: 'sword',
moves: e.moves, techs: [], internal: null,
silver: e.silver, expVal: e.exp, loot: e.loot, tier: e.tier,
glyph: e.glyph || '敌', color: e.color || '#a05c46',
ai: e.ai || 'brute', boss: !!e.boss
});
}
/* ---------------- Battle class ---------------- */
class Battle {
constructor(cfg) {
this.cfg = cfg;
this.allies = cfg.allies.map((u, i) => { u.side = 'ally'; return u; });
this.enemies = cfg.enemies.map(u => { u.side = 'enemy'; return u; });
this.units = this.allies.concat(this.enemies);
this.round = 0;
this.queue = [];
this.turnIdx = -1;
this.current = null;
this.over = false;
this.result = null;
this.phase = 'intro';
this.selectedMove = null; // move being cast
this.reach = null; // map of reachable tiles for current unit
this.floaters = [];
this.banner = null;
this.logLines = [];
this.listeners = {};
this.speed = G.settings.aiSpeed || 380;
this.killsByPlayer = {};
this.techUses = {}; // techId -> uses this battle
this.place();
this.on('start');
}
on(evt, fn) { if (fn) this.listeners[evt] = fn; else if (this.listeners[evt]) this.listeners[evt](); }
emit(evt, data) { if (this.listeners[evt]) this.listeners[evt](data); }
place() {
let ay = 1;
for (const u of this.allies) { u.x = u.isPlayer ? 0 : 1; u.y = ay; ay += 2; if (ay >= GRID_H) ay = 1; }
let ey = Math.floor(GRID_H / 2); let ex = GRID_W - 1;
for (let i = 0; i < this.enemies.length; i++) {
const u = this.enemies[i];
if (i === 0 && this.enemies.length > 2) { u.x = ex - 1; u.y = ey; }
else { u.x = ex; u.y = ey; ey += 2; if (ey >= GRID_H) { ey = 1; ex -= 1; } }
}
}
unitAt(x, y) { return this.units.find(u => u.alive && u.x === x && u.y === y); }
free(x, y) { return x >= 0 && x < GRID_W && y >= 0 && y < GRID_H && !this.unitAt(x, y); }
startRound() {
this.round++;
this.emit('log', `<b>— Round ${this.round} —</b>`);
this.queue = this.units.filter(u => u.alive).sort((a, b) => (b.spd + Util.rf() * 2) - (a.spd + Util.rf() * 2));
this.turnIdx = -1;
this.nextTurn();
}
effSpd(u) { return Math.round(u.spd * (u.sts.some(s => s.k === 'slow') ? 0.99 : 1)); }
effAtk(u) { let m = 1; if (u.sts.some(s => s.k === 'atkup')) m += 0.3; if (u.sts.some(s => s.k === 'burn')) m -= 0.1; return u.atk * m; }
effDef(u) { let m = 1; if (u.sts.some(s => s.k === 'defup')) m += 0.3; return u.def * m; }
hasStatus(u, k) { return u.sts.some(s => s.k === k); }
addStatus(u, k, dur, v) {
if (u.isPlayer || u.npcId) { /* player-side can still be debuffed */ }
const ex = u.sts.find(s => s.k === k);
if (ex) { ex.dur = Math.max(ex.dur, dur); if (v) ex.v = Math.max(ex.v || 0, v); }
else u.sts.push({ k, dur, v: v || 0 });
this.emit('fx', { type: 'status', unit: u, status: k });
}
nextTurn() {
if (this.over) return;
// tick round end?
this.turnIdx++;
if (this.turnIdx >= this.queue.length) { this.startRound(); return; }
const u = this.queue[this.turnIdx];
if (!u.alive) { this.nextTurn(); return; }
// start-of-turn statuses
this.tickStatuses(u);
if (!u.alive) { this.checkEnd(); if (!this.over) this.nextTurn(); return; }
u.moved = false; u.acted = false;
// slow halves movement
u.moveLeft = this.hasStatus(u, 'slow') ? Math.max(1, Math.ceil(this.effMovePts(u) / 2)) : this.effMovePts(u);
this.current = u;
this.reach = this.computeReach(u);
this.emit('refresh');
this.showBanner(u.name);
if (u.ai === 'player') {
this.phase = 'input';
this.emit('input', u);
} else {
this.phase = 'ai';
const delay = this.cfg.instant ? 0 : this.speed;
setTimeout(() => { if (!this.over) this.runAI(u); }, delay);
}
}
effMovePts(u) {
let mv = u.mv;
return mv;
}
showBanner(text) {
this.banner = text;
this.emit('banner', text);
if (!this.cfg.instant) setTimeout(() => { this.banner = null; this.emit('banner', null); }, 700);
else this.banner = null;
}
tickStatuses(u) {
for (const s of u.sts) {
if (s.k === 'poison') { const dmg = Math.max(3, Math.round(u.hpMax * 0.06)); this.damage(u, dmg, { silentCrit: true, srcName: 'poison' }); this.emit('log', `${Util.esc(u.name)} suffers <span class="bad">${dmg}</span> poison damage.`); }
if (s.k === 'bleed') { const dmg = 8 + Math.round(1.5 * (u.lvl || 3)); this.damage(u, dmg, { silentCrit: true, srcName: 'bleeding' }); this.emit('log', `${Util.esc(u.name)} bleeds for <span class="bad">${dmg}</span>.`); }
if (s.k === 'regen') { const h = Math.round(u.hpMax * 0.08); this.heal(u, h); }
}
u.sts = u.sts.filter(s => --s.dur > 0);
}
computeReach(u) {
// BFS over free tiles within moveLeft
const start = { x: u.x, y: u.y };
const dist = {}; dist[start.x + ',' + start.y] = 0;
const q = [[start.x, start.y]];
while (q.length) {
const [cx, cy] = q.shift();
const d = dist[cx + ',' + cy];
if (d >= u.moveLeft) continue;
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) {
if (!dx && !dy) continue;
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || nx >= GRID_W || ny < 0 || ny >= GRID_H) continue;
if (dist[nx + ',' + ny] !== undefined) continue;
if (this.unitAt(nx, ny)) continue;
dist[nx + ',' + ny] = d + 1;
q.push([nx, ny]);
}
}
delete dist[u.x + ',' + u.y];
return dist;
}
moveUnitTo(u, x, y) {
if (this.over) return false;
if (this.current !== u) return false;
if ((this.reach || {})[x + ',' + y] === undefined) return false;
u.x = x; u.y = y; u.moved = true;
u.moveLeft = 0;
this.reach = {};
Sfx.play('click');
this.emit('log', `${Util.esc(u.name)} moves.`);
this.emit('refresh');
if (u.ai !== 'player' && !u.acted) setTimeout(() => { if (!this.over && this.current === u) this.aiActPhase(u); }, this.cfg.instant ? 0 : this.speed * 0.6);
return true;
}
/* -------- actions -------- */
/* resolve a move definition whether it comes from TECHNIQUES or an
inline enemy move list (tech === '__ai') */
resolveMove(u, tech, mi) {
if (tech === '__ai') return { mvd: (u.moves || [])[mi], cdKey: '__ai:' + mi };
const T = TECHNIQUES[tech];
return T ? { mvd: T.moves[mi], cdKey: tech + ':' + mi } : {};
}
canUseMove(u, tech, mi) {
const { mvd, cdKey } = this.resolveMove(u, tech, mi);
if (!mvd) return { ok: false, why: '?' };
if ((u.cds[cdKey] || 0) > 0) return { ok: false, why: 'cooldown' };
if (u.mp < (mvd.mp || 0)) return { ok: false, why: 'no qi' };
if (mvd.costHpPct && u.hp <= u.hpMax * (mv.costHpPct + 0.05)) return { ok: false, why: 'too hurt' };
return { ok: true };
}
beginCast(u, tech, mi) {
if (this.phase !== 'input' || this.current !== u) return;
const chk = this.canUseMove(u, tech, mi);
if (!chk.ok) { Toast.show(chk.why === 'cooldown' ? 'That move is recharging.' : chk.why === 'no qi' ? 'Not enough inner energy.' : 'Cannot use that.'); return; }
this.selectedMove = { tech, mi, def: TECHNIQUES[tech].moves[mi] };
this.emit('refresh');
}
cancelCast() { this.selectedMove = null; this.emit('refresh'); }
targetTilesFor(u, mvd) {
const out = [];
const r = mvd.r || 0;
if (mvd.sh === 'self') return [{ x: u.x, y: u.y }];
if (mvd.sh === 'ally') {
for (const t of this.units.filter(a => a.alive && a.side === u.side)) {
if (Util.dist(u.x, u.y, t.x, t.y) <= Math.max(r, 1)) out.push({ x: t.x, y: t.y });
}
if (r >= 99 || mvd.self_ok) out.push({ x: u.x, y: u.y });
return out;
}
for (let x = 0; x < GRID_W; x++) for (let y = 0; y < GRID_H; y++) {
const d = Util.dist(u.x, u.y, x, y);
if (d === 0 || d > r) continue;
if (mvd.sh === 'foe') { const t = this.unitAt(x, y); if (t && t.side !== u.side) out.push({ x, y }); }
else if (mvd.sh === 'line') { if (x === u.x || y === u.y) out.push({ x, y }); }
else if (mvd.sh === 'cross') { if (d === 1 && (x === u.x || y === u.y)) out.push({ x, y }); }
else if (mvd.sh === 'area') { out.push({ x, y }); }
}
return out;
}
tilesHitByShape(u, mvd, tx, ty) {
const hits = [];
const sameSide = (t) => t.side === u.side;
switch (mvd.sh) {
case 'self': return [u];
case 'ally': { const t = this.unitAt(tx, ty); return t && sameSide(t) ? [t] : []; }
case 'foe': { const t = this.unitAt(tx, ty); return t && !sameSide(t) ? [t] : []; }
case 'line': {
const dirX = tx === u.x ? 0 : Math.sign(tx - u.x);
const dirY = ty === u.y ? 0 : Math.sign(ty - u.y);
let cx = u.x, cy = u.y;
for (let i = 0; i < (mvd.r || 1); i++) {
cx += dirX; cy += dirY;
if (cx < 0 || cx >= GRID_W || cy < 0 || cy >= GRID_H) break;
const t = this.unitAt(cx, cy);
if (t && !sameSide(t)) hits.push(t);
}
return hits;
}
case 'cross': {
const cells = [[tx, ty], [tx - 1, ty], [tx + 1, ty], [tx, ty - 1], [tx, ty + 1]];
for (const [cx, cy] of cells) { const t = this.unitAt(cx, cy); if (t && !sameSide(t)) hits.push(t); }
return hits;
}
case 'area': {
for (const t of this.units) {
if (!t.alive || sameSide(t)) continue;
if (Util.dist(tx, ty, t.x, t.y) <= 1) hits.push(t);
}
return hits;
}
}
return hits;
}
clickTile(x, y) {
if (this.over || this.phase !== 'input') return;
const u = this.current;
if (this.selectedMove) {
const valid = this.targetTilesFor(u, this.selectedMove.def).some(t => t.x === x && t.y === y);
if (!valid) { Toast.show('Out of range for that move.'); return; }
this.castMove(u, this.selectedMove.tech, this.selectedMove.mi, x, y);
this.cancelCast();
return;
}
if (!u.moved && this.reach[x + ',' + y] !== undefined) { this.moveUnitTo(u, x, y); return; }
// default: basic attack on adjacent-ish enemy in weapon range
const t = this.unitAt(x, y);
if (t && t.side !== u.side && Util.dist(u.x, u.y, x, y) <= u.rng && !u.acted) {
this.basicAttack(u, t);
} else if (t && t.side !== u.side) {
Toast.show('Too far away.');
}
}
endTurn() {
if (this.phase !== 'input') return;
this.selectedMove = null;
this.phase = 'between';
this.emit('refresh');
setTimeout(() => { if (!this.over) this.nextTurn(); }, this.cfg.instant ? 0 : 250);
}
basicAttack(u, t) {
u.acted = true;
this.attackRoll(u, t, { n: 'Strike', p: 100 });
this.afterAction(u);
}
attackRoll(u, t, mvd, opts) {
opts = opts || {};
const hitChance = Util.clamp(95 - t.dodge * 0.8, 40, 98);
if (Util.chance(hitChance / 100)) {
let power = mvd.p || 100;
let masteryBonus = 0;
if (opts.techId && u.isPlayer && G.player.techs[opts.techId] >= 100) masteryBonus = 0.10;
let raw = this.effAtk(u) * (power / 100) * masteryFactor(masteryBonus);
raw *= 0.9 + Util.rf() * 0.2;
let dmg = Math.max(1, Math.round(raw - this.effDef(t) * 0.55));
let crit = Util.chance(Util.clamp(u.crit, 0, 60) / 100);
if (crit) dmg = Math.round(dmg * 1.55);
this.damage(t, dmg, { crit });
const cls = crit ? 'crit' : '';
this.emit('log', `<span class="${cls}">${Util.esc(u.name)} \u2014 ${Util.esc(mvd.n)} \u2014 ${Util.esc(t.name)} takes <b>${dmg}</b>${crit ? ' CRIT!' : ''}</span>`);
Sfx.play(crit ? 'crit' : 'hit');
// riders
if (mvd.st && Util.chance(mvd.st.ch)) { this.addStatus(t, mvd.st.k, mvd.st.dur); this.emit('log', `${Util.esc(t.name)} is afflicted: ${mvd.st.k}.`); }
if (mvd.stAll && Util.chance(mvd.stAll.ch)) { this.addStatus(t, mvd.stAll.k, mvd.stAll.dur); this.emit('log', `${Util.esc(t.name)} is afflicted: ${mvd.stAll.k}.`); }
if (u.coatPoisonTurns > 0 && Util.chance(0.6)) { this.addStatus(t, 'poison', 3); }
// serpent touch
if (u.internal === 'i_serpent' && Util.chance(0.05 * (u.internalLv || 1))) this.addStatus(t, 'poison', 2);
if (opts.techId && u.isPlayer) {
this.techUses[opts.techId] = (this.techUses[opts.techId] || 0) + 1;
}
return dmg;
} else {
this.emit('log', `${Util.esc(u.name)}\u2019s ${Util.esc(mvd.n)} misses ${Util.esc(t.name)}.`);
this.emit('fx', { type: 'miss', unit: t });
return 0;
}
}
functionRef_mastery() {}
castMove(u, tech, mi, tx, ty) {
const { mvd, cdKey } = this.resolveMove(u, tech, mi);
if (!mvd) return;
const chk = this.canUseMove(u, tech, mi);
if (!chk.ok) return;
u.mp -= (mvd.mp || 0);
if (mvd.cd) u.cds[cdKey] = mvd.cd + 1; // +1 since cooldowns tick at turn end
if (mvd.costHpPct) { const cost = Math.round(u.hpMax * mvd.costHpPct); u.hp = Math.max(1, u.hp - cost); this.emit('fx', { type: 'hurt', unit: u }); }
u.acted = true;
// self effects
if (mvd.sh === 'self') {
if (mvd.mpGain) { u.mp = Math.min(u.mpMax, u.mp + mvd.mpGain); this.emit('fx', { type: 'mp', unit: u, v: mvd.mpGain }); this.emit('log', `${Util.esc(u.name)} gathers qi (+${mvd.mpGain}).`); }
if (mvd.healPct) { const h = Math.round(u.hpMax * mvd.healPct); this.heal(u, h); this.emit('log', `${Util.esc(u.name)} recovers ${h} health.`, ); }
if (mvd.buff) this.addStatus(u, mvd.buff.stat + 'up', mvd.buff.dur);
if (mvd.shield) this.addShield(u, mvd.shieldFlat ? shieldAmount(u) : mvd.shield);
Sfx.play('heal');
}
// targets
const targets = this.tilesHitByShape(u, mvd, tx, ty);
if (targets.length) {
const hits = mvd.hits || 1;
for (const t of targets) {
if (mvd.p > 0) {
for (let h = 0; h < hits; h++) this.attackRoll(u, t, mvd, { techId: tech });
}
if (mvd.healFlat) { const h = mvd.healFlat; this.heal(t, h); this.emit('log', `${Util.esc(u.name)} heals ${Util.esc(t.name)} for ${h}.`); }
if (mvd.stAlly) { this.addStatus(t, mvd.stAlly.k, mvd.stAlly.dur); }
if (mvd.shield) this.addShield(t, mvd.shield);
}
} else if (mvd.p === 0 && mvd.sh === 'ally') {
// whiffed heal
}
if (u.internal === 'i_sun') { /* regen handled per-round */ }
this.afterAction(u);
}
addShield(u, amount) {
const ex = u.sts.find(s => s.k === 'shield');
if (ex) ex.v += amount; else u.sts.push({ k: 'shield', dur: 99, v: amount });
this.emit('fx', { type: 'shield', unit: u });
}
useInternalActive() {
const u = this.current; if (!u || !u.internal) return;
const key = 'internal';
if ((u.cds[key] || 0) > 0) { Toast.show('Internal art is recharging.'); return; }
const ia = INTERNALS[u.internal]; const cd = ia.cd || 4;
u.cds[key] = cd + 1;
switch (u.internal) {
case 'i_water':
u.sts = u.sts.filter(s => ['shield', 'atkup', 'defup', 'spdup', 'regen'].includes(s.k));
this.emit('log', `${Util.esc(u.name)}\u2019s waters run clear \u2014 debuffs cleansed.`);
break;
case 'i_azure': {
const tgt = this.nearestFoe(u, 2);
if (tgt) this.castDirect(u, tgt, { n: 'Sword Qi Wave', p: 140 }, 15);
else { Toast.show('No target in range.'); u.cds[key] = 0; return; }
break; }
case 'i_vajra': { const amt = 40 + 15 * (u.internalLv || 1); this.addShield(u, amt); u.mp = Math.max(0, u.mp - 12); this.emit('log', `Golden bell rings around ${Util.esc(u.name)} (shield ${amt}).`); Sfx.play('heal'); break; }
case 'i_serpent': {
let any = false;
for (const t of this.units.filter(x => x.alive && x.side !== u.side && Util.dist(u.x, u.y, x.x, x.y) <= 1)) {
this.addStatus(t, 'poison', 3); any = true; this.emit('log', `${Util.esc(t.name)} is wreathed in venom mist.`);
}
u.mp = Math.max(0, u.mp - 14);
if (!any) { this.emit('log', `${Util.esc(u.name)} exhales venom into empty air.`); }
break; }
case 'i_sun': {
const tgt = this.bestLineTarget(u, 2);
u.mp = Math.max(0, u.mp - 20);
if (tgt) this.lineBlast(u, tgt, { n: 'Solar Flare', p: 165 });
else this.emit('log', `${Util.esc(u.name)}\u2019s flare scorches only dust.`);
break; }
}
u.acted = true;
this.afterAction(u);
}
castDirect(u, t, mvd, mpCost) { u.mp = Math.max(0, u.mp - (mpCost || 0)); this.attackRoll(u, t, mvd); }
bestLineTarget(u, range) {
for (const t of this.units.filter(x => x.alive && x.side !== u.side)) {
if ((t.x === u.x || t.y === u.y) && Util.dist(u.x, u.y, t.x, t.y) <= range) return t;
}
return null;
}
lineBlast(u, t, mvd) {
const dirX = t.x === u.x ? 0 : Math.sign(t.x - u.x);
const dirY = t.y === u.y ? 0 : Math.sign(t.y - u.y);
let cx = u.x, cy = u.y;
for (let i = 0; i < 2; i++) {
cx += dirX; cy += dirY;
const hit = this.unitAt(cx, cy);
if (hit && hit.side !== u.side) this.attackRoll(u, hit, mvd);
if (hit && hit.side === u.side) break; // blocked by ally
}
}
afterAction(u) {
// decrement cooldowns
for (const k of Object.keys(u.cds)) u.cds[k] = Math.max(0, u.cds[k] - 1);
this.checkEnd();
if (this.over) return;
this.emit('refresh');
if (u.ai !== 'player') {
setTimeout(() => { if (!this.over && this.current === u) this.nextTurn(); }, this.cfg.instant ? 0 : 350);
} else {
if (u.moved && u.acted) this.endTurn();
else { this.phase = 'input'; this.emit('input', u); }
}
}
heal(u, amount) {
const before = u.hp;
u.hp = Math.min(u.hpMax, u.hp + amount);
const real = u.hp - before;
if (real > 0) this.emit('fx', { type: 'heal', unit: u, v: real });
return real;
}
damage(t, amount, opts) {
opts = opts || {};
if (!t.alive) return 0;
// shield absorb
const sh = t.sts.find(s => s.k === 'shield');
let dmg = amount;
if (sh) {
const absorbed = Math.min(sh.v, dmg);
sh.v -= absorbed; dmg -= absorbed;
if (sh.v <= 0) t.sts = t.sts.filter(s => s !== sh);
if (absorbed > 0) this.emit('fx', { type: 'block', unit: t, v: absorbed });
}
t.hp = Math.max(0, t.hp - dmg);
this.emit('fx', { type: opts.crit ? 'crit' : 'dmg', unit: t, v: dmg });
if (t.hp <= 0) {
t.alive = false;
this.emit('log', `<b>${Util.esc(t.name)} falls!</b>`);
if (t.side === 'enemy' && this.current && this.current.side === 'ally') {
const killer = this.current;
if (killer.isPlayer) this.killsByPlayer[t.tier || 1] = (this.killsByPlayer[t.tier || 1] || 0) + 1;
}
// quest counters
if (typeof Game !== 'undefined' && Game.registerKill) Game.registerKill(t);
}
return dmg;
}
checkEnd() {
if (this.over) return;
if (!this.enemies.some(u => u.alive)) this.finish(true);
else if (!this.allies.some(u => u.alive)) this.finish(false);
}
finish(win) {
this.over = true; this.win = win;
this.phase = 'done';
// sync player hp/mp back
const pu = this.allies.find(u => u.isPlayer);
if (pu && G.player) { G.player.hp = Math.max(0, pu.hp); G.player.mp = Math.max(0, pu.mp); }
this.emit('end', { win, kills: this.killsByPlayer, techUses: this.techUses });
}
/* -------- AI -------- */
nearestFoe(u, maxD) {
let best = null, bd = 99;
for (const t of this.units) {
if (!t.alive || t.side === u.side) continue;
const d = Util.dist(u.x, u.y, t.x, t.y);
if (d < bd) { bd = d; best = t; }
}
return (bd <= (maxD || 99)) ? best : null;
}
runAI(u) {
if (this.over || !u.alive) return;
// decide approach first
const foe = this.pickAITarget(u);
if (!foe) { this.endTurnAI(u); return; }
// healer/support special
if (u.ai === 'support') {
const woundedAlly = this.units.filter(a => a.alive && a.side === u.side && a.hp < a.hpMax * 0.65 && a !== u)
.sort((a, b) => (a.hp / a.hpMax) - (b.hp / b.hpMax))[0];
if (woundedAlly && Util.dist(u.x, u.y, woundedAlly.x, woundedAlly.y) <= 4 && u.mp >= 12) {
// find a healing move
const cast = this.aiFindMove(u, (mvd) => !!mvd.healFlat);
if (cast) { this.castMove(u, cast.tech, cast.mi, woundedAlly.x, woundedAlly.y); return; }
}
}
// choose a move to use vs foe
const dist = Util.dist(u.x, u.y, foe.x, foe.y);
const usable = this.aiUsableMoves(u);
// try moves whose range reaches the foe
for (const c of usable.sort((a, b) => (b.mvd.p || 0) - (a.mvd.p || 0))) {
if (c.mvd.sh === 'self') continue;
const reachR = c.mvd.sh === 'area' ? c.mvd.r : (c.mvd.r || 1);
if (dist <= reachR) {
// aim: for line/cross/foe target the foe tile
this.castMove(u, c.tech, c.mi, foe.x, foe.y);
return;
}
}
// self-buff if nothing to hit and hasn't acted
const buff = usable.find(c => c.mvd.sh === 'self');
if (buff && Util.chance(0.5)) { this.castMove(u, buff.tech, buff.mi, u.x, u.y); return; }
// else move toward foe then act if possible
this.aiApproachAndAct(u, foe);
}
endTurnAI(u) {
this.checkEnd();
if (this.over) return;
setTimeout(() => { if (!this.over && this.current === u) this.nextTurn(); }, this.cfg.instant ? 0 : 300);
}
aiUsableMoves(u) {
const out = [];
const pool = u.moves ? null : u.techs;
const list = u.moves || [];
list.forEach((mvd, mi) => {
if ((u.cds['ai:' + mi] || 0) > 0) return;
if (u.mp < (mvd.mp || 0)) return;
out.push({ tech: '__ai', mi, mvd });
});
// cooldown bookkeeping for AI moves keyed differently
if (list.length) {
for (let mi = 0; mi < list.length; mi++) if ((u.cds['__ai:' + mi] || 0) > 0) {}
}
for (const tid of u.techs || []) {
const T = TECHNIQUES[tid]; if (!T) continue;
T.moves.forEach((mvd, mi) => {
const profReq = PROF_REQ[mi] || 0;
const prof = u.isPlayer ? (G.player.techs[tid] || 0) : 100;
if (prof < profReq) return;
if ((u.cds[tid + ':' + mi] || 0) > 0) return;
if (u.mp < (mvd.mp || 0)) return;
out.push({ tech: tid, mi, mvd });
});
}
return out;
}
aiFindMove(u, pred) {
return this.aiUsableMoves(u).find(c => pred(c.mvd));
}
pickAITarget(u) {
const foes = this.units.filter(t => t.alive && t.side !== u.side);
if (!foes.length) return null;
if (u.ai === 'archer' || u.ai === 'skirmish' || u.ai === 'caster') {
// prefer lowest hp
foes.sort((a, b) => a.hp - b.hp); return foes[0];
}
if (u.ai === 'boss' || u.ai === 'duelist') {
// prefer player
const pl = foes.find(f => f.isPlayer);
return pl || foes[0];
}
foes.sort((a, b) => Util.dist(u.x, u.y, a.x, a.y) - Util.dist(u.x, u.y, b.x, b.y));
return foes[0];
}
aiApproachAndAct(u, foe) {
// BFS step toward the foe: pick reachable tile minimizing distance to foe
const reach = this.computeReach(u);
let bestTile = null, bd = Util.dist(u.x, u.y, foe.x, foe.y);
for (const key of Object.keys(reach)) {
const [x, y] = key.split(',').map(Number);
const d = Util.dist(x, y, foe.x, foe.y);
if (d < bd) { bd = d; bestTile = { x, y }; }
}
const doActAfterMove = () => {
const nd = Util.dist(u.x, u.y, foe.x, foe.y);
if (nd <= u.rng && !u.acted) { this.basicAttackAI(u, foe); }
else {
const usable = this.aiUsableMoves(u).filter(c => c.mvd.sh !== 'self' && nd <= (c.mvd.sh === 'area' ? c.mvd.r : (c.mvd.r || 1)));
if (usable.length && !u.acted) { const c = usable.sort((a, b) => (b.mvd.p || 0) - (a.mvd.p || 0))[0]; this.castMove(u, c.tech, c.mi, foe.x, foe.y); }
else if (!u.acted && nd <= 1) { this.basicAttackAI(u, foe); }
else this.endTurnAI(u);
}
};
if (bestTile) {
u.x = bestTile.x; u.y = bestTile.y; u.moved = true;
this.emit('refresh');
if (this.cfg.instant) doActAfterMove();
else setTimeout(doActAfterMove, this.speed * 0.55);
} else {
doActAfterMove();
}
}
basicAttackAI(u, t) {
u.acted = true;
this.attackRoll(u, t, { n: 'Strike', p: 100 });
this.afterAction(u);
}
}
function masteryFactor(b) { return 1 + b; }
function shieldAmount(u) { return 40 + 15 * (u.internalLv || 1); }
+451
View File
@@ -0,0 +1,451 @@
'use strict';
/* ============================================================
DATA: dialogues + data validation. All original fiction.
Node: { t: string|fn(G), opts: fn(G)->[opt] | array }
opt: { l: label, cond?: fn, act?(G), next?: nodeId,
close?, shop?: npcId }
============================================================ */
const DIALOGUES = {};
function defDlg(id, o) { o.id = id; DIALOGUES[id] = o; }
/* ---- Elder Chen ---- */
defDlg('d_elder', { name: 'Elder Chen', nodes: {
start: { t: (G) => {
const q = G.quests['q_main_1'];
if (!q) return 'Ah, awake at last. Trouble, I\u2019m afraid \u2014 wolves have come down off the ridge bold as tax collectors. Three flocks torn this week.';
if (!q.done && q.stage === 1) return `The ridge, child. The wolves won\u2019t cull themselves. (${Math.min(G.flags.wolfKills || 0, 3)}/3)`;
if (!q.done && q.stage === 2) return 'Back already? And the ridge?';
if (q.done) return 'You carry the look of the wide roads now. The village is proud of you.';
return 'Speak, child.';
},
opts: (G) => {
const o = []; const q = G.quests['q_main_1'];
if (!q) {
o.push({ l: 'I\u2019ll deal with the wolves.', act: () => Game.startQuest('q_main_1'), next: 'accept' });
o.push({ l: 'Why me?', next: 'why' });
} else if (!q.done && q.stage === 2) {
o.push({ l: 'The wolves are dealt with.', act: () => { Game.completeQuest('q_main_1', { exp: 45, silver: 60, fame: 5 }); G.flags.unlockedCity = true; G.flags.mainChapter = 2; }, next: 'reward' });
} else if (q.done && !G.flags.missingAsked) {
o.push({ l: 'Anything else troubling the village?', next: 'missing' });
}
if (G.flags.unlockedCity && !G.flags.toldAboutYan) {
o.push({ l: 'What lies beyond the village?', next: 'beyond' });
}
o.push({ l: 'Leave.', close: true });
return o;
}},
why: { t: () => 'Because you\u2019re young, armed, and the militia is twelve farmers with pitchforks. Also \u2014 your late father cleared wolves from this same ridge. Habit runs in blood.',
opts: [{ l: 'Then it runs in mine. I\u2019ll go.', act: () => Game.startQuest('q_main_1'), next: 'accept' }, { l: 'Find someone else.', close: true }] },
accept: { t: () => 'Good. Take the north path to Cloudmist Ridge. Three kills will teach the pack manners. And \u2014 here, bread for the road.',
opts: [{ l: '(Take the bread)', act: () => Game.addItem('c_bun', 2), close: true }] },
reward: { t: () => 'The flocks thank you, though they don\u2019t know it. Sixty silver from the village fund \u2014 and listen: the road east to Qingyun City is open to you. A person of your hands shouldn\u2019t stay cooped in a village.',
opts: [{ l: 'Thank you, Elder.', close: true }] },
missing: { t: () => 'One thing gnaws at me. Old Uncle Ping went to cut bamboo three days back and never returned. His grandniece cries at the gate every evening. If you pass the Bamboo Sea\u2026 ask Hunter Ren. Please.',
opts: [{ l: 'I\u2019ll look for him.', act: () => { G.flags.missingAsked = true; Game.startQuest('q_missing'); }, close: true }, { l: 'I can\u2019t, not now.', close: true }] },
beyond: { t: () => 'Qingyun City \u2014 money, schools, knives with opinions. The Garrison Liaison there keeps watch on the mountain roads. If those wolves were pushed onto us by something worse, tell Captain Yan. Tell her Elder Chen\u2019s beard stood white before its time.',
opts: [{ l: 'I\u2019ll remember it.', act: () => { G.flags.toldAboutYan = true; }, close: true }] }
}});
/* ---- Xiaoli ---- */
defDlg('d_kid', { name: 'Xiaoli', nodes: {
start: { t: () => Util.pick([
'They say the Ghost Market only opens after dark, down by the docks! I\u2019m NOT supposed to know that.',
'Uncle Wang in Ember Town makes swords better if you bring him ore!',
'Miss Lin from the mountain sect beats up ALL the senior disciples. Even the big ones!',
'If you meet a drunk beggar in the city\u2026 lose to him on purpose. Trust me!',
'Alchemist Bai pays GOOD for snake guts. Gross, but good.',
'The tournament happens every week when the drum tower hits five!'
]),
opts: (G) => {
const o = [];
if ((G.aff['n_kid_xiaoli'] || 0) >= 20 && Game.hasItem('gift_cakes')) {
o.push({ l: '(Give her the Osmanthus Cakes)', act: () => Game.giveGift('n_kid_xiaoli', 'gift_cakes'), next: 'candy' });
}
o.push({ l: 'See you around, Xiaoli.', close: true });
return o;
}},
candy: { t: () => 'OSMANTHUS! You\u2019re my favorite hero ever!', opts: [{ l: '(Warm feeling acquired)', close: true }] }
}});
/* ---- innkeepers ---- */
defDlg('d_inn_ma', { name: 'Innkeeper Ma', nodes: { start: { t: () => 'Rooms are eight coppers, congee\u2019s free, gossip\u2019s extra. Rest up, dear.',
opts: [
{ l: 'Rent a room (-8 silver)', act: () => Game.restInn(8), close: true },
{ l: 'Just passing through.', close: true }
]}}});
defDlg('d_inn_zhou', { name: 'Innkeeper Zhou', nodes: { start: { t: () => 'Welcome to the Golden Carp! Best beds in Qingyun \u2014 the pillows alone are famous.',
opts: [
{ l: 'Rent a room (-14 silver)', act: () => Game.restInn(14), close: true },
{ l: 'Another time.', close: true }
]}}});
defDlg('d_inn_lu', { name: 'Innkeeper Lu', nodes: { start: { t: () => 'Miners snore, smiths snore louder. You look quieter. Room\u2019s ten.',
opts: [
{ l: 'Rent a room (-10 silver)', act: () => Game.restInn(10), close: true },
{ l: 'Later.', close: true }
]}}});
/* ---- Merchant Su ---- */
defDlg('d_su', { name: 'Merchant Su', nodes: {
start: { t: (G) => (G.aff['n_merchant_su'] || 0) > 40
? 'My favorite customer! Fresh stock from the river barges \u2014 look, don\u2019t touch unless buying. Touching counts as buying.'
: 'Welcome, welcome! Su & Sons \u2014 everything a traveler needs and several things they don\u2019t.',
opts: [
{ l: 'Browse wares', shop: 'n_merchant_su' },
{ l: 'Any news?', next: 'news' },
{ l: 'Leave.', close: true }
]},
news: { t: () => Util.pick([
'Word is the Iron Fist Guild recruits at the garrison. Big Guo breaks bricks AND egos.',
'A silver-umbrella gentleman has been asking after tournament fighters. Rich type. Wrong smile.',
'Pelts fetch fair silver at the Bamboo Sea cabin. Hunter Ren doesn\u2019t haggle, bless him.',
'The Pure Lotus Temple trains anyone who sweeps the courtyard without complaining. Nobody manages it.'
]), opts: [{ l: 'Interesting.', next: '_back' }] },
_back: { t: () => 'Anything else?',
opts: [ { l: 'Browse wares', shop: 'n_merchant_su' }, { l: 'Leave.', close: true } ]}
}});
/* ---- Captain Yan ---- */
defDlg('d_yan', { name: 'Captain Yan', nodes: {
start: { t: (G) => {
const s = G.quests['q_main_2'] ? (G.quests['q_main_2'].done ? 99 : G.quests['q_main_2'].stage) : -1;
if (s === -1 && G.flags.unlockedCity) return 'State your business. Quickly \u2014 the paperwork breeds when unwatched.';
if (s === 1) return 'The bandit camp squats in the old shrine hollow on Cloudmist Ridge. Their leader calls himself "One-Eye" Qiang. Clean it out.';
if (s === 2) return 'Qiang\u2019s camp is on the Ridge. Come back when he\u2019s retired from brigandage.';
if (s === 3) return 'Searched the camp yet? Evidence, wanderer. Wars are fought with paper more than swords.';
if (s === 99 && G.quests['q_main_3'] && !G.quests['q_main_3'].done && G.quests['q_main_3'].stage <= 1) return 'The Ghost Market, under the docks, after dusk. Umbrellas hate daylight and so do their secrets.';
if (G.quests['q_main_4'] && !G.quests['q_main_4'].done && G.quests['q_main_4'].stage < 2) return 'Allies and standing. No one faces the Iron Umbrella alone \u2014 not even you.';
if (G.quests['q_main_4'] && !G.quests['q_main_4'].done && G.quests['q_main_4'].stage === 2) return 'So. Companions gathered, a school behind you. Now the hard part.';
if (G.quests['q_main_5'] && !G.quests['q_main_5'].done) return 'The tomb past the Ridge. The Ledger. You know what rides on it.';
return 'The Garrison watches the roads. Lately the roads need watching \u2014 umbrella-men in the mists.';
},
opts: (G) => {
const o = [];
const q2 = G.quests['q_main_2']; const s2 = q2 && !q2.done ? q2.stage : -1;
if (!q2 && G.flags.unlockedCity) o.push({ l: 'Elder Chen sent me \u2014 wolves driven onto Willow Creek.', next: 'brief' });
if (s2 === 1) o.push({ l: 'I\u2019ll handle the camp.', close: true });
if ((s2 === 3 || s2 === 4) && Game.hasItem('mat_token')) o.push({ l: 'This lead token was hidden under Qiang\u2019s cot.', next: 'token' });
const q4 = G.quests['q_main_4'];
if (q4 && !q4.done && q4.stage === 1 && (G.rep.garrison || 0) >= 30) o.push({ l: `The garrison trusts me now. (${G.rep.garrison}/30)`, act: () => Game.setStage('q_main_4', 2), next: 'trustok' });
if (q4 && !q4.done && q4.stage === 2) o.push({ l: 'I\u2019ve gathered my strength. What\u2019s the hard part?', act: () => { Game.completeQuest('q_main_4', { exp: 300, silver: 250, fame: 20 }); G.flags.mainChapter = 4; Game.startQuest('q_main_5'); }, next: 'ledger' });
if (G.quests['q_main_6'] && !G.quests['q_main_6'].done) o.push({ l: 'Report the Ledger secured.', close: true });
o.push({ l: 'Leave.', close: true });
return o;
}},
brief: { t: () => 'Wolves, wasps, and now bandits \u2014 the Ridge crawls. There\u2019s a standing bounty: the camp in the shrine hollow, chief "One-Eye" Qiang especially. Do it, and the Garrison remembers its friends.',
opts: [{ l: 'I\u2019ll take the bounty.', act: () => Game.startQuest('q_main_2'), close: true },
{ l: 'Who are these bandits?', next: 'whobandits' }] },
whobandits: { t: () => 'Deserters, smugglers, men who owed money to worse men. But their discipline is wrong for rabble \u2014 someone drills them. Find out who, and the payment doubles in gratitude.',
opts: [{ l: 'Understood.', act: () => Game.startQuest('q_main_2'), close: true }] },
token: { t: () => 'An umbrella stamped in lead\u2026 So the rumors have claws after all. The Iron Umbrella Society \u2014 money-lenders, information brokers\u2026 and increasingly, an army without a flag. They move behind festivals and tournaments.',
opts: [{ l: '(Hand over the token)', act: () => { Game.removeItem('mat_token', 1); Game.completeQuest('q_main_2', { exp: 160, silver: 200, fame: 15 }); G.flags.iuKnown = true; Game.startQuest('q_main_3'); }, next: 'umbrella' }] },
umbrella: { t: () => 'The Qingyun tournament runs every fifth day. Their agents walk straight into such rings. Get into the finals. Win. Watch who comes to collect the losers \u2014 then bring me proof, not corpses. The Garrison cannot move against ghosts\u2026 but between us? Corpses that deserve it tend to become ghosts anyway. Go.',
opts: [{ l: 'Consider it done.', close: true }] },
trustok: { t: () => 'Thirty points of garrison trust doesn\u2019t grow on trees. Good. You\u2019ll need every friend when the storm comes.',
opts: [{ l: 'What storm?', act: () => Game.setStage('q_main_4', 2), close: true }] },
ledger: { t: () => 'There is a ledger. The Heaven\u2019s Ledger \u2014 every bribe, every bought magistrate, every umbrella-order signed by hand. It sleeps in the Ancient Tomb past the Ridge, behind doors that have killed better people than me. The Iron Umbrella wants it burned; we want it read. Whoever holds it owns the truth. Move before they do.',
opts: [{ l: 'I\u2019ll reach it first.', close: true }] }
}});
/* ---- Host Qian / tournament ---- */
defDlg('d_qian', { name: 'Host Qian', nodes: {
start: { t: (G) => {
if (G.quests['q_main_3'] && !G.quests['q_main_3'].done && G.quests['q_main_3'].stage === 3) return 'You\u2019re in the finals, champ! But between us \u2014 that silver-umbrella gentleman asked me to pass a message. He wants to meet. Alone. Behind the arena.';
if (Game.isTournamentDay() && G.tourney.lastDay !== G.time.day) return 'Fighters! Spectators! Betters of silver! The weekly Martial Tournament is OPEN! Shall I ink your name?';
if (G.tourney.lastDay === G.time.day) return 'Bracket\u2019s closed today, hero \u2014 come back when the drum tower next calls the fifth day!';
return 'Tournament runs each fifth day. Champions get silver, glory, and my professional envy.';
},
opts: (G) => {
const o = [];
if (Game.isTournamentDay() && G.tourney.lastDay !== G.time.day) o.push({ l: 'Enter the tournament!', act: () => { Dialogue.close(); Game.enterTournament(); } });
if (G.quests['q_main_3'] && !G.quests['q_main_3'].done && G.quests['q_main_3'].stage === 3) o.push({ l: 'A private meeting, hm\u2026', act: () => { Dialogue.close(); Game.dukangAmbush(); } });
o.push({ l: 'Leave.', close: true });
return o;
}}
}});
/* ---- Smith Wang ---- */
defDlg('d_wang', { name: 'Smith Wang', nodes: {
start: { t: (G) => (G.aff['n_smith_wang'] || 0) > 40 ? 'Back again? Ha! Bring ore, leave silver, both of us leave happy.' : 'Forge is hot, prices are fair, complaints go in the quench bucket. Browse or upgrade?',
opts: [
{ l: 'Browse wares', shop: 'n_smith_wang' },
{ l: 'Upgrade equipment', act: () => UI.openSmith(), close: true },
{ l: 'Leave.', close: true }
]}}});
/* ---- Alchemist Bai ---- */
defDlg('d_bai', { name: 'Alchemist Bai', nodes: {
start: { t: (G) => {
const q = G.quests['q_cure'];
if (q && !q.done && q.stage === 1) return 'Snake gall and green herbs \u2014 got them?';
if (!q) return 'Fresh remedies, honest prices. Hmm \u2014 actually, since you look capable: I need snake gallbladder and green herbs for a fever cure. Help me, and I\u2019ll share my real recipes.';
return 'The kettle never lies and neither do I. Remedies? Brews?';
},
opts: (G) => {
const o = []; const q = G.quests['q_cure'];
if (!q) o.push({ l: 'What do you need exactly?', act: () => Game.startQuest('q_cure'), next: 'need' });
if (q && !q.done && q.stage === 1 && Game.hasItem('mat_gall', 2) && Game.hasItem('c_herb_green', 3)) {
o.push({ l: 'Deliver ingredients (2 gall, 3 herbs)', act: () => { Game.removeItem('mat_gall', 2); Game.removeItem('c_herb_green', 3); Game.completeQuest('q_cure', { exp: 90, silver: 80, items: [['c_pill_hp', 2]], aff: [['n_alchemist_bai', 18]] }); G.flags.knowsAlchemy = true; }, next: 'thanks' });
}
o.push({ l: 'Browse remedies', shop: 'n_alchemist_bai' });
if (G.flags.knowsAlchemy) o.push({ l: 'Brew together', act: () => { Dialogue.close(); UI.openAlchemy(); } });
o.push({ l: 'Leave.', close: true });
return o;
}},
need: { t: () => 'Two snake gallbladders \u2014 the vipers on the Bamboo trails are perfect \u2014 and three handfuls of green herb. The fever cure won\u2019t brew itself, and children won\u2019t stop being small and fragile.',
opts: [{ l: 'I\u2019ll get them.', close: true }] },
thanks: { t: () => 'Wonderful! The little ones will sleep tonight. As promised \u2014 my real recipes. Come brew anytime, and take these pills for your troubles.',
opts: [{ l: 'Glad to help.', close: true }] }
}});
/* ---- Hunter Ren ---- */
defDlg('d_ren', { name: 'Hunter Ren', nodes: {
start: { t: (G) => {
const q = G.quests['q_missing'];
if (q && !q.done && q.stage === 1) return 'Ping? Aye, he came through two days back, headed deep \u2014 past the third bend, where the bamboo grows black-tight. Fool\u2019s route this season.';
return 'City folk. Well \u2014 you\u2019ve woodsmoke in your jacket at least. Pelts, fangs, or silence: choose.';
},
opts: (G) => {
const o = []; const q = G.quests['q_missing'];
if (Game.hasItem('mat_pelt', 3)) o.push({ l: 'Trade 3 Wolf Pelts for 60 silver', act: () => { Game.removeItem('mat_pelt', 3); Game.addSilver(60); Log.add('Traded pelts for 60 silver.', 'good'); } });
if (Game.hasItem('mat_fang', 4)) o.push({ l: 'Trade 4 Wolf Fangs for 50 silver', act: () => { Game.removeItem('mat_fang', 4); Game.addSilver(50); Log.add('Traded fangs for 50 silver.', 'good'); } });
if (q && !q.done && q.stage === 1) o.push({ l: 'Which way past the third bend?', act: () => Game.setStage('q_missing', 2), next: 'deep' });
if ((G.aff['n_hunter_ren'] || 0) >= 25 && !G.player.techs['t_blade_1']) o.push({ l: 'Teach me your saber forms (free, friends only)', act: () => { Dialogue.close(); Game.learnTechnique('t_blade_1'); } });
if (Game.canSpar('n_hunter_ren')) o.push({ l: 'Spar with Ren', act: () => { Dialogue.close(); Game.spar('n_hunter_ren'); } });
o.push({ l: 'Leave.', close: true });
return o;
}},
deep: { t: () => 'Keep the moss on your LEFT. Birds go quiet, draw steel first and apologize after. And if you find him\u2026 bring words back, not just bones.',
opts: [{ l: 'Understood.', act: () => { Dialogue.close(); Game.searchDeepBamboo(); } }] }
}});
/* ---- Zhao Tiehu ---- */
defDlg('d_tiehu', { name: 'Zhao Tiehu', nodes: {
start: { t: (G) => {
if (!G.allies.includes('n_tiehu') && (FVsafe('mainChapter') >= 3) && (G.aff['n_tiehu'] || 0) >= 40) return 'Wanderer! They say you cracked a conspiracy in front of the whole city. My arms are strong but my curiosity is stronger \u2014 take me along, will you?';
return 'Arm-wrestling champion of Peach Blossom Dock, three years running! Care to test the legend? Or drink? Or both, in strict order?';
},
opts: (G) => {
const o = [];
if (!G.allies.includes('n_tiehu') && FVsafe('mainChapter') >= 3 && (G.aff['n_tiehu'] || 0) >= 40) o.push({ l: 'Join me, Tiehu!', act: () => Game.recruitCompanion('n_tiehu'), next: 'joined' });
if (Game.canSpar('n_tiehu')) o.push({ l: 'Arm-wrestle / Spar (friendly)', act: () => { Dialogue.close(); Game.spar('n_tiehu'); } });
o.push({ l: 'Drinking contest (Constitution test, 20 silver stake)', act: () => { Dialogue.close(); Game.drinkingContest(); } });
o.push({ l: 'Leave.', close: true });
return o;
}},
joined: { t: () => 'HA! Adventure, conspiracy, beautiful scenery \u2014 count me in! Just don\u2019t ask me to read anything longer than a menu.',
opts: [{ l: 'Welcome aboard.', close: true }] }
}});
function FVsafe(k){ return G.flags[k] || 0; }
/* ---- Ferryman ---- */
defDlg('d_ferry', { name: 'Old Gao the Ferryman', nodes: {
start: { t: () => TimeSys.isNight()
? 'Night tide\u2019s right. Two silvers rows you to the shore without lanterns. You didn\u2019t hear it from me.'
: 'Come back when the sun\u2019s drowned, friend. Daylight passengers ride ordinary boats to ordinary places.',
opts: (G) => {
const o = [];
if (TimeSys.isNight()) o.push({ l: 'Row me over (-2 silver)', cond: () => G.player.silver >= 2, act: () => { if (Game.spendSilver(2)) { Dialogue.close(); Game.travelTo('l_market'); } } });
o.push({ l: 'Wave goodbye.', close: true });
return o;
}}}});
/* ---- Abbot Lian ---- */
defDlg('d_abbot', { name: 'Abbot Lian', nodes: {
start: { t: (G) => (G.aff['n_abbot_lian'] || 0) > 50 ? 'Ah, the wandering bell returns. Sit, breathe. The leaves fell badly today \u2014 very suspicious of the wind.' : 'The courtyard welcomes those whose steps disturb neither dust nor dew. How may the temple serve?',
opts: (G) => {
const o = [];
const qf = G.quests['q_trial_fl'];
if (!G.flags.templeMember && !qf && !G.flags.azureMember) o.push({ l: 'Ask to study at Pure Lotus Temple', act: () => Game.startQuest('q_trial_fl'), next: 'trialfl' });
if (qf && !qf.done && qf.stage === 1) o.push({ l: 'Report: courtyard swept, breath stilled, monk sparred', act: () => { Game.completeQuest('q_trial_fl', { exp: 100, rep: [['temple', 25]], aff: [['n_abbot_lian', 12]] }); G.flags.templeMember = true; Game.addRep('temple', 10); }, next: 'memberfl' });
for (const t of NPCS.n_abbot_lian.teach) {
if (Game.alreadyKnows(t)) continue;
const ok = Game.canLearnFrom('n_abbot_lian', t);
const lbl = t.internal ? ('Internal art: ' + INTERNALS[t.internal].name) : ('Technique: ' + TECHNIQUES[t.tech].name);
o.push({ l: lbl + (ok.ok ? ` (${t.cost} silver)` : ` (${ok.why})`), act: () => { Dialogue.close(); Game.learnFrom('n_abbot_lian', t); } });
}
if (G.flags.sunHint && !G.player.knownInternals.includes('i_sun') && !Game.teachListHas(NPCS.n_abbot_lian, 'internal', 'i_sun')) {
o.push({ l: 'Ask of the lost Sun Manual.', next: 'sunhint2' });
}
o.push({ l: 'Leave.', close: true });
return o;
}},
trialfl: { t: () => '\u201cThe temple turns away no one willing to be bored properly.\u201d He hands you a broom and a cushion. \u201cOne honest sitting in the hall, one friendly round with Brother Jing. Then we talk about sutras.\u201d',
opts: [{ l: '(Accept broom and cushion)', close: true }] },
memberfl: { t: () => '\u201cBoredom, survived.\u201d The Abbot almost smiles. \u201cWelcome into the lotus gate, disciple. Mind the third step on your way out \u2014 it bites.\u201d\u2026 no wait, that\u2019s the valley.',
opts: [{ l: 'Thank you, Abbot.', close: true }] },
sunhint2: { t: () => 'So the beggar told you. Yes \u2014 the Blazing Sun Manual was carried into the Ancient Tomb three hundred years ago by its last master. If the tomb opens to you, look where the light pools at noon. Such things guard themselves.',
opts: [{ l: 'Thank you, Abbot.', close: true }] }
}});
// expose i_sun teaching after hint (append dynamically)
NPCS.n_abbot_lian.teach.push({ internal: 'i_sun', aff: 90, cost: 2000, reqFlag: 'sunHint' });
/* unified namespace referenced across modules */
const DATA = { ITEMS, TECHNIQUES, INTERNALS, LIGHTNESS, ENEMIES, NPCS, LOCS, QUESTS, ORIGINS, DIALOGUES };
/* ---- Monk Jing ---- */
defDlg('d_jing', { name: 'Monk Jing', nodes: {
start: { t: () => 'The training posts are free; my company is cheaper. Spar with me, friend \u2014 gently. Or not. The bruises are instructive either way.',
opts: (G) => {
const o = [];
if (Game.canSpar('n_monk_jing')) o.push({ l: 'Spar with Monk Jing', act: () => { Dialogue.close(); Game.spar('n_monk_jing'); } });
o.push({ l: 'Peace upon you.', close: true });
return o;
}}}});
/* ---- Master Yun ---- */
defDlg('d_yun', { name: 'Master Yun', nodes: {
start: { t: (G) => {
if (!G.flags.azureMember && !G.quests['q_trial_az']) return 'A new face on the peak. The Azure Cloud Sect accepts students of decent heart \u2014 and tests them first. Interested?';
const q = G.quests['q_trial_az'];
if (q && !q.done && q.stage === 1) return 'The Lotus Herbs grow where the Bamboo Sea thins. Two, unbruised. Patience, not strength.';
if (G.flags.azureMember) return 'Disciple. The forms await \u2014 and so does the mission hall, if your blade is restless.';
return 'The peak keeps its own counsel. Speak.';
},
opts: (G) => {
const o = []; const q = G.quests['q_trial_az'];
if (!G.flags.azureMember && !q) o.push({ l: 'I wish to study under Azure Cloud.', act: () => Game.startQuest('q_trial_az'), next: 'trial' });
if (q && !q.done && q.stage === 1 && Game.hasItem('c_lotus', 2)) o.push({ l: 'Deliver 2 Lotus Herbs', act: () => { Game.removeItem('c_lotus', 2); Game.completeQuest('q_trial_az', { exp: 120, rep: [['azure', 25]], aff: [['n_master_yun', 15]] }); G.flags.azureMember = true; Game.addRep('azure', 10); }, next: 'member' });
for (const t of NPCS.n_master_yun.teach) {
if (Game.alreadyKnows(t)) continue;
const ok = Game.canLearnFrom('n_master_yun', t);
const lbl = t.internal ? ('Internal art: ' + INTERNALS[t.internal].name) : ('Technique: ' + TECHNIQUES[t.tech].name);
o.push({ l: lbl + (ok.ok ? ` (${t.cost} silver)` : ` (${ok.why})`), act: () => { Dialogue.close(); Game.learnFrom('n_master_yun', t); } });
}
o.push({ l: 'Leave.', close: true });
return o;
}},
trial: { t: () => 'Good. The trial is humility with leaves: bring me two Lotus Herbs from the Bamboo Sea below. They bruise if you stare at them wrongly. Go.',
opts: [{ l: 'As you say, Master.', close: true }] },
member: { t: () => 'Unbruised. Excellent. From this morning you walk beneath our clouds. Welcome to Azure Cloud, disciple.',
opts: [{ l: 'Thank you, Master.', close: true }] }
}});
/* ---- Lin Wan'er ---- */
defDlg('d_lin', { name: 'Lin Wan\u2019er', nodes: {
start: { t: (G) => {
if (G.partner === 'n_lin') return 'There you are. I saved you the sunny side of the terrace. Also I beat three senior brothers while waiting. A productive morning.';
if ((G.aff['n_lin'] || 0) >= 80 && FVsafe('mainChapter') >= 3 && !G.partner) return 'You\u2019re leaving again soon, aren\u2019t you. Somewhere with conspiracies in it. \u2026Idiot. Take me with you. Not as a disciple-babysitter. As\u2014 as yours. If you want.';
if (!G.allies.includes('n_lin') && FVsafe('mainChapter') >= 3 && (G.aff['n_lin'] || 0) >= 50) return 'So the famous umbrella-hunter wants ME at their side? Beat me first. Properly. Then we\u2019ll talk about roads.';
return 'Oh \u2014 the wanderer. Heard you fight umbrellas. I collect sword styles; show me yours sometime.';
},
opts: (G) => {
const o = [];
if ((G.aff['n_lin'] || 0) >= 80 && FVsafe('mainChapter') >= 3 && !G.partner) o.push({ l: 'Then come with me \u2014 as yours, and mine.', act: () => Game.confess('n_lin'), next: 'confessed' });
else if (!G.allies.includes('n_lin') && FVsafe('mainChapter') >= 3 && (G.aff['n_lin'] || 0) >= 50) o.push({ l: 'Then let\u2019s duel for it.', act: () => { Dialogue.close(); Game.linDuel(); } });
if (Game.canSpar('n_lin')) o.push({ l: 'Spar with Wan\u2019er', act: () => { Dialogue.close(); Game.spar('n_lin'); } });
o.push({ l: 'Leave.', close: true });
return o;
}},
confessed: { t: () => '\u2026You\u2019re supposed to hesitate dramatically at least ONCE. \u2014Fine. FINE. Yes. Obviously yes. If you die on some stupid road I\u2019ll resurrect you and kill you myself.',
opts: [{ l: 'Deal.', close: true }] }
}});
/* ---- Lady Miao ---- */
defDlg('d_miao', { name: 'Lady Miao', nodes: {
start: { t: (G) => G.flags.serpentMember ? 'Little serpent returns. Mind the third step, it bites.' : 'A guest who walks between the poison patches uninvited. Either lucky or destined \u2014 both taste the same in my tea.',
opts: (G) => {
const o = [];
if (!G.flags.serpentMember) o.push({ l: 'Ask to study in Serpent Veil Valley', act: () => { G.flags.serpentMember = true; Game.addRep('serpent', 15); Log.add('You are now a sworn friend of Serpent Veil Valley.', 'sys'); }, next: 'join' });
for (const t of NPCS.n_lady_miao.teach) {
if (Game.alreadyKnows(t)) continue;
const ok = Game.canLearnFrom('n_lady_miao', t);
const lbl = t.internal ? ('Internal art: ' + INTERNALS[t.internal].name) : ('Technique: ' + TECHNIQUES[t.tech].name);
o.push({ l: lbl + (ok.ok ? ` (${t.cost} silver)` : ` (${ok.why})`), act: () => { Dialogue.close(); Game.learnFrom('n_lady_miao', t); } });
}
o.push({ l: 'Leave.', close: true });
return o;
}},
join: { t: () => 'Amusing. Very well \u2014 the Valley marks you friend. Touch nothing shiny, drink nothing offered, and visit often. We\u2019re terribly welcoming.',
opts: [{ l: '\u2026Noted.', close: true }] }
}});
/* ---- Su Qingluo ---- */
defDlg('d_suqing', { name: 'Su Qingluo', nodes: {
start: { t: (G) => {
if (G.partner === 'n_suqing') return 'Back so soon? Hold still \u2014 there\u2019s bamboo dust in your collar. Honestly, adventures are just dirt with plot.';
if (!G.allies.includes('n_suqing') && FVsafe('mainChapter') >= 2 && (G.aff['n_suqing'] || 0) >= 45) return 'Miao says I should see the world before it sees me. You seem\u2027 structurally sound. Take me traveling, physician\u2019s orders.';
if (!G.flags.qingluoMet) return 'New face. Don\u2019t touch the purple ones. Or breathe too deep. Welcome to the valley!';
return 'Need something brewed, or just admiring the poison garden?';
},
opts: (G) => {
const o = [];
if (!G.flags.qingluoMet) o.push({ l: 'Just passing through.', act: () => { Game.addAffinity('n_suqing', 5); G.flags.qingluoMet = true; }, close: true });
if (!G.allies.includes('n_suqing') && FVsafe('mainChapter') >= 2 && (G.aff['n_suqing'] || 0) >= 45) o.push({ l: 'Travel with me, Qingluo.', act: () => Game.recruitCompanion('n_suqing'), next: 'joined' });
o.push({ l: 'Ask her about herbs.', next: 'herbs' });
o.push({ l: 'Leave.', close: true });
return o;
}},
joined: { t: () => 'Excellent. I\u2019m bringing the leeches. Kidding! \u2026Mostly. Don\u2019t die stupidly, that\u2019s all I ask.', opts: [{ l: 'Welcome aboard.', close: true }] },
herbs: { t: () => 'Lotus herb only grows in mist \u2014 the Bamboo Sea, or right here if Lady Miao isn\u2019t watching. Snake gall keeps best fresh, so hunt vipers last. And if something numbles your tongue, spit and run.', opts: [{ l: 'Useful. Grim, but useful.', close: true }] }
}});
/* ---- Master Guo ---- */
defDlg('d_guo', { name: 'Master Guo', nodes: {
start: { t: (G) => G.flags.fistMember ? 'Guild sibling! Bricks don\u2019t break themselves.' : 'Iron Fist Guild. We punch things professionally. You look like you might have two functional fists. Interested?',
opts: (G) => {
const o = [];
if (!G.flags.fistMember) o.push({ l: 'Join the Iron Fist Guild (100 silver dues)', cond: () => G.player.silver >= 100, act: () => { if (Game.spendSilver(100)) { G.flags.fistMember = true; Game.addRep('fist', 15); Log.add('Welcome to the Iron Fist Guild!', 'sys'); } }, next: 'joinedg' });
for (const t of NPCS.n_master_guo.teach) {
if (Game.alreadyKnows(t)) continue;
const ok = Game.canLearnFrom('n_master_guo', t);
o.push({ l: 'Technique: ' + TECHNIQUES[t.tech].name + (ok.ok ? ` (${t.cost} silver)` : ` (${ok.why})`), act: () => { Dialogue.close(); Game.learnFrom('n_master_guo', t); } });
}
if (Game.canSpar('n_master_guo')) o.push({ l: 'Spar with Master Guo', act: () => { Dialogue.close(); Game.spar('n_master_guo'); } });
o.push({ l: 'Leave.', close: true });
return o;
}},
joinedg: { t: () => 'Dues collected, hand shaken \u2014 possibly cracked. Welcome to the guild, sibling. Now go hit something responsibly.', opts: [{ l: 'Yes sir.', close: true }] }
}});
/* ---- Drill-Captain Pei ---- */
defDlg('d_pei', { name: 'Drill-Captain Pei', nodes: {
start: { t: () => 'The bounty board pays for problems. The Garrison pays double when the problem wears an umbrella. Read the board, take a contract, keep your receipts.',
opts: [
{ l: 'Check the military bounty board', act: () => { Dialogue.close(); UI.openBoard(true); } },
{ l: 'Leave.', close: true }
]}}});
/* ---- Broker Qiu ---- */
defDlg('d_broker', { name: 'Broker Qiu', nodes: {
start: { t: (G) => {
if (G.quests['q_main_3'] && !G.quests['q_main_3'].done && G.quests['q_main_3'].stage === 1) return 'Ah, the person everyone\u2019s whispering about. I know why the umbrellas flock to Qingyun\u2019s tournament ring. That knowledge costs eighty silver. Knowledge always costs; that\u2019s how you know it\u2019s good.';
return 'Secrets, maps, introductions. Everything has a price; everything has a previous owner.';
},
opts: (G) => {
const o = [];
const q3 = G.quests['q_main_3'];
if (q3 && !q3.done && q3.stage === 1) o.push({ l: 'Buy the intel (-80 silver)', cond: () => G.player.silver >= 80, act: () => { if (Game.spendSilver(80)) { Game.setStage('q_main_3', 2); Game.addRep('underground', 5); } }, next: 'intel' });
o.push({ l: 'Buy map rumor: reveal hidden places (-50 silver)', cond: () => !G.discovered['l_valley'] || !G.discovered['l_market'], act: () => { if (Game.spendSilver(50)) { G.discovered['l_valley'] = true; G.discovered['l_market'] = true; Toast.show('Map knowledge gained.'); } } });
o.push({ l: 'Leave.', close: true });
return o;
}},
intel: { t: () => 'Wise purchase. Listen: the Society plants a ringer in the tournament finals \u2014 a duelist called Du Kang, silver umbrella, silk smile. He collects promising losers for their cause, and buries stubborn ones. Win to the finals, unmask him publicly, and the umbrella\u2019s grip on this city slips. The rest of the intel\u2019s free: don\u2019t drink anything he pours.',
opts: [{ l: 'Silver well spent.', close: true }] }
}});
/* ---- Fence Wu ---- */
defDlg('d_fence', { name: 'Fence Wu', nodes: {
start: { t: () => 'Everything here fell off a wagon. Some wagons were on fire. Browse anyway.',
opts: [
{ l: 'Browse acquisitions', shop: 'n_fence_wu' },
{ l: 'Leave.', close: true }
]}}});
/* ---- Hong the Beggar ---- */
defDlg('d_hong', { name: 'Hong the Beggar', nodes: {
start: { t: (G) => {
if ((G.flags.hongBeats || 0) >= 2 && !G.player.techs['t_fist_4']) return 'Twice. Twice you\u2019ve put grandpa Hong in the dirt! HAHAHA! Fine, fine \u2014 come closer. No, closer. \u2026These old palms have drunk ten thousand rivers. Watch closely now.';
if (G.flags.hongBeats === 1) return 'One win apiece, eh? Again! Grandpa hasn\u2019t been knocked down since the Emperor\u2019s grandmother was a girl. It felt NOSTALGIC.';
return '*hic* The wine here\u2019s watered, the streets are crooked, and yet \u2014 young people keep stepping over me with fists like unopened letters. Spar with an old man, hm?';
},
opts: (G) => {
const o = [];
if ((G.flags.hongBeats || 0) >= 2 && !G.player.techs['t_fist_4']) o.push({ l: 'Learn the Ten Thousand Rivers Palms', act: () => { Dialogue.close(); Game.learnHongArt(); } });
if (Game.canSpar('n_beggar_hong')) o.push({ l: 'Spar with Hong', act: () => { Dialogue.close(); Game.spar('n_beggar_hong'); } });
if (Game.hasItem('c_wine')) o.push({ l: 'Share your Mulled Wine', act: () => Game.giveGift('n_beggar_hong', 'c_wine'), next: 'wine' });
o.push({ l: 'Step over him politely.', close: true });
return o;
}},
wine: { t: () => 'AH! A person of culture and poor judgment! Sit, sit. Did you know the Pure Lotus Temple keeps a certain manual locked where noon-light lands? Neither did I. *hic* Probably nothing.', opts: [{ l: 'Probably nothing\u2026', act: () => { G.flags.sunHint = true; Toast.show('Rumor learned: the lost Sun Manual sleeps in the Ancient Tomb.'); }, close: true }] }
}});
+330
View File
@@ -0,0 +1,330 @@
'use strict';
/* ============================================================
DATA: items, techniques, internal arts, lightness, enemies
All names & lore are original fiction.
============================================================ */
const ITEMS = {};
function defItem(id, o) { o.id = id; ITEMS[id] = o; }
/* ---------------- ITEMS ---------------- */
defItem('w_sword_1', { name: 'Rusty Sword', type: 'weapon', slot: 'weapon', wt: 'sword', atk: 6, price: 40, rar: 0, desc: 'A pitted jian passed down too many times.' });
defItem('w_sword_2', { name: 'Cold-Forged Jian', type: 'weapon', slot: 'weapon', wt: 'sword', atk: 14, price: 220, rar: 1, desc: 'Quenched in mountain snow. Balanced and keen.' });
defItem('w_sword_3', { name: 'Azure Edge', type: 'weapon', slot: 'weapon', wt: 'sword', atk: 26, price: 900, rar: 2, desc: 'Sect-forged steel with a blue-grey gleam.' });
defItem('w_sword_4', { name: 'Voice of the River', type: 'weapon', slot: 'weapon', wt: 'sword', atk: 40, crit: 5, price: 2600, rar: 3, desc: 'It hums softly when drawn, like water over stone.' });
defItem('w_blade_1', { name: 'Hunting Saber', type: 'weapon', slot: 'weapon', wt: 'blade', atk: 7, price: 45, rar: 0, desc: 'Heavy-backed and honest, made for firewood and wolves.' });
defItem('w_blade_2', { name: 'Wolf-Fang Dao', type: 'weapon', slot: 'weapon', wt: 'blade', atk: 16, price: 240, rar: 1, desc: 'Notched like fangs. Wolves fear it on sight.' });
defItem('w_blade_3', { name: 'Autumn Moon Blade', type: 'weapon', slot: 'weapon', wt: 'blade', atk: 28, price: 950, rar: 2, desc: 'Curved like the harvest moon, cold as its light.' });
defItem('w_blade_4', { name: 'Severing Cloud', type: 'weapon', slot: 'weapon', wt: 'blade', atk: 42, crit: 5, price: 2700, rar: 3, desc: 'Legend says one stroke parted a stormfront.' });
defItem('w_spear_1', { name: 'Iron-Tip Spear', type: 'weapon', slot: 'weapon', wt: 'spear', rng: 2, atk: 7, price: 50, rar: 0, desc: 'Reach beats rage, as granthands say.' });
defItem('w_spear_2', { name: 'Serpent Spear', type: 'weapon', slot: 'weapon', wt: 'spear', rng: 2, atk: 15, price: 250, rar: 1, desc: 'A sinuous shaft that strikes like a viper.' });
defItem('w_spear_3', { name: 'Dragonhead Lance', type: 'weapon', slot: 'weapon', wt: 'spear', rng: 2, atk: 27, price: 1000, rar: 2, desc: 'Carved dragon muzzles hide a triple-edged point.' });
defItem('w_spear_4', { name: 'Storm Piercer', type: 'weapon', slot: 'weapon', wt: 'spear', rng: 2, atk: 41, crit: 5, price: 2800, rar: 3, desc: 'Thrust fast enough and the thunder follows.' });
defItem('w_staff_1', { name: 'Oak Staff', type: 'weapon', slot: 'weapon', wt: 'staff', atk: 6, mp: 5, price: 35, rar: 0, desc: 'Straight, sturdy, and forgiving to beginners.' });
defItem('w_staff_2', { name: 'Moonlit Cudgel', type: 'weapon', slot: 'weapon', wt: 'staff', atk: 13, mp: 10, price: 210, rar: 1, desc: 'White as bone under moonlight.' });
defItem('w_staff_3', { name: 'Demon-Subduing Staff', type: 'weapon', slot: 'weapon', wt: 'staff', atk: 25, mp: 18, price: 920, rar: 2, desc: 'Temple ironwood, blessed a hundred nights.' });
defItem('w_staff_4', { name: 'Lotus Heart Staff', type: 'weapon', slot: 'weapon', wt: 'staff', atk: 38, mp: 28, price: 2600, rar: 3, desc: 'A lotus seed sits in its hollow core, forever unfalling.' });
defItem('w_fist_1', { name: 'Cloth Hand Wraps', type: 'weapon', slot: 'weapon', wt: 'fist', atk: 5, spd: 1, price: 25, rar: 0, desc: 'Simple linen. The knuckles provide the rest.' });
defItem('w_fist_2', { name: 'Iron Palm Guards', type: 'weapon', slot: 'weapon', wt: 'fist', atk: 12, spd: 1, price: 180, rar: 1, desc: 'Weighted leather that turns fists into hammers.' });
defItem('w_fist_3', { name: 'Tiger-Bone Knuckles', type: 'weapon', slot: 'weapon', wt: 'fist', atk: 24, spd: 2, price: 850, rar: 2, desc: 'Ground tiger-bone lacquered over iron rings.' });
defItem('w_fist_4', { name: 'Mountain-Shaking Fists', type: 'weapon', slot: 'weapon', wt: 'fist', atk: 37, spd: 2, crit: 5, price: 2400, rar: 3, desc: 'Old Guo swears the mountain felt these.' });
defItem('w_hidden_1', { name: 'Throwing Darts', type: 'weapon', slot: 'weapon', wt: 'hidden', rng: 4, atk: 5, price: 30, rar: 0, desc: 'A sleeve full of sharp arguments.' });
defItem('w_hidden_2', { name: 'Sleeved Needles', type: 'weapon', slot: 'weapon', wt: 'hidden', rng: 4, atk: 11, price: 200, rar: 1, desc: 'Thin as rain, quiet as regret.' });
defItem('w_hidden_3', { name: 'Plum-Flower Darts', type: 'weapon', slot: 'weapon', wt: 'hidden', rng: 5, atk: 22, price: 880, rar: 2, desc: 'They bloom in five-pointed patterns mid-flight.' });
defItem('w_hidden_4', { name: 'Rain of Sanskrit', type: 'weapon', slot: 'weapon', wt: 'hidden', rng: 5, atk: 34, crit: 5, price: 2300, rar: 3, desc: 'Each needle engraved with a single silent word.' });
defItem('ar_head_1', { name: 'Straw Hat', type: 'armor', slot: 'head', def: 2, price: 15, rar: 0, desc: 'Keeps off rain and rude glances alike.' });
defItem('ar_head_2', { name: 'Iron Skullcap', type: 'armor', slot: 'head', def: 5, price: 90, rar: 1, desc: 'Dented, but the dents are on the outside.' });
defItem('ar_head_3', { name: 'Wind Hood', type: 'armor', slot: 'head', def: 8, spd: 1, price: 320, rar: 2, desc: 'Cut so wind parts around the wearer.' });
defItem('ar_head_4', { name: 'Crown of Stillness', type: 'armor', slot: 'head', def: 12, mp: 20, price: 1100, rar: 3, desc: 'A meditation band of cold dark iron.' });
defItem('ar_body_1', { name: 'Padded Vest', type: 'armor', slot: 'body', def: 3, price: 30, rar: 0, desc: 'Layered rags stitched with hope.' });
defItem('ar_body_2', { name: 'Leather Cuirass', type: 'armor', slot: 'body', def: 6, price: 140, rar: 1, desc: 'Boiled leather, road-tested.' });
defItem('ar_body_3', { name: 'Ring Mail', type: 'armor', slot: 'body', def: 11, hp: 20, price: 480, rar: 2, desc: 'A thousand iron whispers woven into cloth.' });
defItem('ar_body_4', { name: 'Cloud-Silk Robe', type: 'armor', slot: 'body', def: 17, hp: 40, spd: 2, price: 1600, rar: 3, desc: 'Woven by cloud-dwellers; blades slide off like rain.' });
defItem('ar_foot_1', { name: 'Straw Sandals', type: 'armor', slot: 'feet', def: 1, spd: 1, price: 12, rar: 0, desc: 'One pair per thousand li.' });
defItem('ar_foot_2', { name: 'Deer-Hide Boots', type: 'armor', slot: 'feet', def: 3, spd: 2, price: 85, rar: 1, desc: 'Soft, silent, sure-footed.' });
defItem('ar_foot_3', { name: 'Cloudstep Shoes', type: 'armor', slot: 'feet', def: 5, spd: 3, price: 300, rar: 2, desc: 'The soles are said to barely kiss the ground.' });
defItem('ar_foot_4', { name: 'Windrunner Greaves', type: 'armor', slot: 'feet', def: 8, spd: 5, price: 1050, rar: 3, desc: 'Shin-guards of layered crane-feather and steel.' });
defItem('ac_jade', { name: 'Jade Pendant', type: 'acc', bonus: { cha: 2 }, price: 150, rar: 1, desc: 'Warm to the touch, and to conversation.' });
defItem('ac_bracer', { name: 'Leather Bracer', type: 'acc', atk: 4, price: 120, rar: 1, desc: 'Studded wrist-guard favored by caravan guards.' });
defItem('ac_rope', { name: 'Silk Rope Belt', type: 'acc', bonus: { agi: 2 }, price: 180, rar: 1, desc: 'Braided from the silk of a hundred moths.' });
defItem('ac_scarf', { name: 'Crimson Scarf', type: 'acc', crit: 6, price: 220, rar: 1, desc: 'Red like a duelist\u2019s promise.' });
defItem('ac_prayer', { name: 'Prayer Beads', type: 'acc', mp: 25, price: 200, rar: 1, desc: 'One bead per dawn prayer, worn smooth.' });
defItem('ac_eye', { name: 'Amber Eye Amulet', type: 'acc', bonus: { luk: 3 }, price: 350, rar: 2, desc: 'Something ancient watches out for you.' });
defItem('ac_goldbell', { name: 'Golden Bell', type: 'acc', def: 5, hp: 30, price: 800, rar: 3, desc: 'Its chime scatters killing intent.' });
defItem('ac_dragonring', { name: 'Dragon-Scale Ring', type: 'acc', atk: 8, crit: 5, price: 1500, rar: 4, desc: 'Taken from the hoard of a river dragon. Allegedly.' });
defItem('c_bun', { name: 'Steamed Bun', type: 'use', use: { hp: 25 }, price: 5, rar: 0, desc: 'Fluffy, faintly sweet, endlessly reliable.' });
defItem('c_fish', { name: 'River Carp', type: 'use', use: { hp: 35 }, price: 15, rar: 0, giftTags: ['drinker', 'kind'], likesVal: 2, desc: 'Fresh from Peach Blossom waters; grill for best effect.' });
defItem('j_boot', { name: 'Waterlogged Boot', type: 'mat', price: 2, rar: 0, desc: 'Size unknown. Owner unknown. Best not to wonder.' });
defItem('c_herb_green', { name: 'Green Herb', type: 'use', use: { hp: 40 }, price: 12, rar: 0, desc: 'Chew fresh for minor wounds.' });
defItem('c_herb_red', { name: 'Red Herb', type: 'use', use: { hp: 90 }, price: 30, rar: 0, desc: 'Bitter root that quickens the blood.' });
defItem('c_lotus', { name: 'Lotus Herb', type: 'mat', price: 22, rar: 1, desc: 'Grows only in mist. Prized by alchemists and sects.' });
defItem('c_pill_hp', { name: 'Healing Pill', type: 'use', use: { hp: 170 }, price: 65, rar: 1, desc: 'Alchemist-crafted; smells of licorice and iron.' });
defItem('c_pill_mp', { name: 'Clear-Mind Pill', type: 'use', use: { mp: 70 }, price: 70, rar: 1, desc: 'Restores inner energy. Tastes of winter air.' });
defItem('c_antidote', { name: 'Antidote Powder', type: 'use', use: { cure: 'poison' }, price: 35, rar: 1, desc: 'Neutralizes most common venoms.' });
defItem('c_wine', { name: 'Mulled Wine', type: 'use', use: { hp: 15, mp: 25 }, price: 18, rar: 0, giftTags: ['drinker', 'fighter'], likesVal: 3, desc: 'Spiced and warm even when cold.' });
defItem('c_elixir_power', { name: 'Battle Elixir', type: 'use', use: { buff: { stat: 'atk', pct: 0.3, dur: 3 }, battle: true }, price: 90, rar: 2, desc: 'Drink in combat: +30% attack for 3 turns.' });
defItem('c_elixir_swift', { name: 'Swift Elixir', type: 'use', use: { buff: { stat: 'spd', pct: 0.3, dur: 3 }, battle: true }, price: 90, rar: 2, desc: 'Drink in combat: +30% speed for 3 turns.' });
defItem('c_venom', { name: 'Coat of Venom', type: 'use', use: { coatPoison: true, battle: true }, price: 80, rar: 2, desc: 'Apply in combat: your attacks poison foes for 3 turns.' });
defItem('c_tonic', { name: 'Vital Tonic', type: 'use', use: { permHp: 8 }, price: 160, rar: 2, desc: 'Permanently raises max health (up to 10 uses).' });
defItem('c_fragment', { name: 'Ancient Fragment', type: 'use', use: { permWit: 1 }, price: 700, rar: 3, desc: 'A torn page of unknowable script. Permanently +1 Wit (max 3).' });
defItem('mat_pelt', { name: 'Wolf Pelt', type: 'mat', price: 14, rar: 0, desc: 'Thick winter fur, still faintly wild-smelling.' });
defItem('mat_fang', { name: 'Wolf Fang', type: 'mat', price: 10, rar: 0, desc: 'Used in elixirs and lots of tall tales.' });
defItem('mat_ore_1', { name: 'Iron Ore', type: 'mat', price: 18, rar: 0, desc: 'Common but indispensable.' });
defItem('mat_ore_2', { name: 'Cold Iron Ore', type: 'mat', price: 55, rar: 1, desc: 'Never warms to the touch. Forged at high altitude.' });
defItem('mat_ore_3', { name: 'Meteorite Shard', type: 'mat', price: 240, rar: 3, desc: 'Star-metal, humming faintly. Smiths dream of it.' });
defItem('mat_gall', { name: 'Snake Gallbladder', type: 'mat', price: 40, rar: 1, desc: 'Handle carefully. Alchemists pay well.' });
defItem('mat_ghost_ink', { name: 'Ghost-Market Ink', type: 'mat', price: 120, rar: 2, desc: 'Writes only by nightlight. Do not read what you sign.' });
defItem('mat_token', { name: 'Iron Umbrella Token', type: 'mat', key: true, price: 0, rar: 2, desc: 'A black umbrella stamped on lead. Someone powerful issued this.' });
defItem('mat_ledger', { name: 'The Heaven\u2019s Ledger', type: 'mat', key: true, price: 0, rar: 4, desc: 'Names, dates, bribes \u2014 the whole rotten architecture of a conspiracy.' });
defItem('gift_tea', { name: 'Cloud-Mist Tea', type: 'gift', price: 45, rar: 1, giftTags: ['scholar', 'monk'], val: 4, desc: 'Grown where clouds forget to move.' });
defItem('gift_fan', { name: 'Painted Fan', type: 'gift', price: 60, rar: 1, giftTags: ['scholar', 'noble'], val: 4, desc: 'Mountains in eleven brushstrokes.' });
defItem('gift_hairpin', { name: 'Jade Hairpin', type: 'gift', price: 130, rar: 2, giftTags: ['romance'], val: 7, desc: 'Carved as a willow leaf. Say something nice when giving it.' });
defItem('gift_dagger', { name: 'Ornamental Dagger', type: 'gift', price: 90, rar: 1, giftTags: ['fighter'], val: 5, desc: 'Too pretty for work, too sharp for decoration.' });
defItem('gift_cakes', { name: 'Osmanthus Cakes', type: 'gift', price: 25, rar: 0, giftTags: ['kind', 'youth'], val: 3, desc: 'Sweet, floral, gone in minutes.' });
defItem('gift_silk', { name: 'Imperial-Green Silk', type: 'gift', price: 160, rar: 2, giftTags: ['noble'], val: 6, desc: 'One bolt, worth a farmer\u2019s year.' });
defItem('gift_book', { name: 'Annotated Classic', type: 'gift', price: 110, rar: 2, giftTags: ['scholar'], val: 6, desc: 'Margins full of a dead reader\u2019s arguments.' });
defItem('m_t_sword_2', { name: 'Manual: Mistfall Sword Art', type: 'manual', teach: 't_sword_2', price: 400, rar: 2, desc: 'Teaches the Mistfall Sword Art.' });
defItem('m_t_blade_2', { name: 'Manual: Falling Meteor Blade', type: 'manual', teach: 't_blade_2', price: 420, rar: 2, desc: 'Teaches the Falling Meteor Blade.' });
defItem('m_t_spear_2', { name: 'Manual: Serpent Spear Song', type: 'manual', teach: 't_spear_2', price: 420, rar: 2, desc: 'Teaches the Serpent Spear Song.' });
defItem('m_t_staff_2', { name: 'Manual: Demon-Subduing Rod', type: 'manual', teach: 't_staff_2', price: 420, rar: 2, desc: 'Teaches the Demon-Subduing Rod method.' });
defItem('m_t_fist_2', { name: 'Manual: Tiger-Bone Boxing', type: 'manual', teach: 't_fist_2', price: 420, rar: 2, desc: 'Teaches Tiger-Bone Boxing.' });
defItem('m_t_hidden_2', { name: 'Manual: Sleeved Needle Art', type: 'manual', teach: 't_hidden_2', price: 420, rar: 2, desc: 'Teaches the Sleeved Needle Art.' });
defItem('m_i_azure', { name: 'Manual: Azure Cloud Canon', type: 'manual', teach: 'i_azure', price: 800, rar: 3, desc: 'Internal art of Azure Cloud Sect.' });
defItem('m_i_vajra', { name: 'Manual: Vajra Body Sutra', type: 'manual', teach: 'i_vajra', price: 800, rar: 3, desc: 'Temple internal art of iron-like flesh.' });
defItem('m_i_serpent', { name: 'Manual: Serpent Breath', type: 'manual', teach: 'i_serpent', price: 800, rar: 3, desc: 'Valley internal art; venom answers your call.' });
defItem('m_l_cloud', { name: 'Manual: Cloud-Chasing Steps', type: 'manual', teach: 'l_cloud', price: 500, rar: 2, desc: 'Lightness skill of the wandering couriers.' });
defItem('m_l_shadow', { name: 'Manual: Phantom Shadow Trace', type: 'manual', teach: 'l_shadow', price: 1500, rar: 3, desc: 'Walk where shadows thin. Nobody sells this twice.' });
/* ---------------- TECHNIQUES ---------------- */
const TECHNIQUES = {};
function defTech(id, o) { o.id = id; TECHNIQUES[id] = o; }
const PROF_REQ = [0, 20, 45, 75];
// shapes: foe | line | cross | area | self | ally
defTech('t_univ_1', { name: 'Breath Control', wt: 'universal', tier: 1, desc: 'Basic breathing every traveler learns.', moves: [
{ n: 'Focus', mp: 0, p: 0, r: 0, sh: 'self', mpGain: 25 },
{ n: 'Second Wind', mp: 10, p: 0, r: 0, sh: 'self', healPct: 0.18 },
{ n: 'Pressure Point', mp: 14, p: 90, r: 1, sh: 'foe', st: { k: 'slow', ch: 0.5, dur: 2 } }
]});
defTech('t_sword_1', { name: 'Riverwind Swordplay', wt: 'sword', tier: 1, desc: 'Flowing basics every sword student begins with.', moves: [
{ n: 'Flowing Thrust', mp: 8, p: 118, r: 1, sh: 'foe' },
{ n: 'Parting Clouds', mp: 14, p: 132, r: 1, sh: 'line', st: { k: 'bleed', ch: 0.4, dur: 2 } }
]});
defTech('t_sword_2', { name: 'Mistfall Sword Art', wt: 'sword', tier: 2, desc: 'Falling-mist footwork feeding whisper-fast cuts.', moves: [
{ n: 'Cold Ripple', mp: 10, p: 128, r: 1, sh: 'foe' },
{ n: 'Moonveil', mp: 18, p: 148, r: 1, sh: 'foe', buff: { stat: 'atk', pct: 0.2, dur: 2 } },
{ n: 'Autumn River', mp: 30, p: 168, r: 1, sh: 'line' }
]});
defTech('t_sword_3', { name: 'Azure Cloud Sword Canon', wt: 'sword', tier: 3, desc: 'The sect\u2019s orthodox canon \u2014 sky-wide, cloud-cold.', moves: [
{ n: 'Cloud Cleave', mp: 16, p: 146, r: 1, sh: 'foe' },
{ n: 'Windride Step', mp: 20, p: 126, r: 1, sh: 'foe', buff: { stat: 'spd', pct: 0.3, dur: 2 } },
{ n: 'Nine Skies Slash', mp: 40, p: 205, r: 2, sh: 'foe', cd: 2 }
]});
defTech('t_sword_4', { name: 'Swordless Heart', wt: 'sword', tier: 4, desc: 'Beyond technique. The cut arrives before the thought.', moves: [
{ n: 'Formless Edge', mp: 24, p: 165, r: 1, sh: 'foe', ignoreDef: true },
{ n: 'Ten-Thousand Sword Rain', mp: 46, p: 95, r: 3, sh: 'area', cd: 3 }
]});
defTech('t_blade_1', { name: 'Tiger Fang Dao', wt: 'blade', tier: 1, desc: 'Direct, heavy hacking in the hunters\u2019 style.', moves: [
{ n: 'Rend', mp: 8, p: 122, r: 1, sh: 'foe' },
{ n: 'Battle Howl', mp: 10, p: 0, r: 0, sh: 'self', buff: { stat: 'atk', pct: 0.25, dur: 3 } }
]});
defTech('t_blade_2', { name: 'Falling Meteor Blade', wt: 'blade', tier: 2, desc: 'Overhead strokes with crushing momentum.', moves: [
{ n: 'Meteor Crash', mp: 15, p: 145, r: 1, sh: 'foe', st: { k: 'stun', ch: 0.25, dur: 1 } },
{ n: 'Flame Arc', mp: 22, p: 138, r: 1, sh: 'cross' }
]});
defTech('t_blade_3', { name: 'Mountain Splitter', wt: 'blade', tier: 3, desc: 'Split what stands; scare what doesn\u2019t.', moves: [
{ n: 'Split Stone', mp: 26, p: 175, r: 1, sh: 'foe', cd: 1 },
{ n: 'Earthshaker', mp: 42, p: 185, r: 1, sh: 'area', cd: 3, st: { k: 'slow', ch: 0.5, dur: 2 } }
]});
defTech('t_blade_4', { name: 'Severing Cloud Dao', wt: 'blade', tier: 4, desc: 'One flash, then the thunder. Your body pays the toll.', moves: [
{ n: 'Cloudsever', mp: 30, p: 198, r: 1, sh: 'foe' },
{ n: 'One Flash', mp: 34, p: 255, r: 1, sh: 'foe', cd: 4, costHpPct: 0.08 }
]});
defTech('t_spear_1', { name: 'Basic Spearwork', wt: 'spear', tier: 1, desc: 'Keep them at the shaft\u2019s end.', moves: [
{ n: 'Skewer', mp: 8, p: 120, r: 2, sh: 'foe' },
{ n: 'Backswing', mp: 12, p: 122, r: 2, sh: 'foe', st: { k: 'slow', ch: 0.35, dur: 2 } }
]});
defTech('t_spear_2', { name: 'Serpent Spear Song', wt: 'spear', tier: 2, desc: 'Coil, feint, strike. The spear sings in circles.', moves: [
{ n: 'Snake Strike', mp: 14, p: 138, r: 2, sh: 'foe', st: { k: 'poison', ch: 0.45, dur: 3 } },
{ n: 'Coil Guard', mp: 16, p: 0, r: 0, sh: 'self', shield: 35, buff: { stat: 'def', pct: 0.25, dur: 2 } }
]});
defTech('t_spear_3', { name: 'Dragonhead Doctrine', wt: 'spear', tier: 3, desc: 'Three thrusts where others manage one.', moves: [
{ n: 'Dragon Flurry', mp: 20, p: 88, r: 2, sh: 'foe', hits: 2 },
{ n: 'War Roar Thrust', mp: 34, p: 182, r: 2, sh: 'line', cd: 2 }
]});
defTech('t_spear_4', { name: 'Storm Piercer Art', wt: 'spear', tier: 4, desc: 'The lance becomes lightning seeking ground.', moves: [
{ n: 'Thunderbolt', mp: 32, p: 205, r: 3, sh: 'foe', cd: 2 },
{ n: 'Thousand Rain Falls', mp: 48, p: 108, r: 4, sh: 'area', cd: 4 }
]});
defTech('t_staff_1', { name: 'Monk\u2019s Staff Basics', wt: 'staff', tier: 1, desc: 'Sweeping arcs and steady breath.', moves: [
{ n: 'Low Sweep', mp: 8, p: 112, r: 1, sh: 'cross' },
{ n: 'Healing Chant', mp: 12, p: 0, r: 4, sh: 'ally', healFlat: 45 }
]});
defTech('t_staff_2', { name: 'Demon-Subduing Rod', wt: 'staff', tier: 2, desc: 'Temple forms to humble bullies and demons alike.', moves: [
{ n: 'Subdue', mp: 15, p: 138, r: 1, sh: 'foe', st: { k: 'stun', ch: 0.35, dur: 1 } },
{ n: 'Vajra Shield', mp: 18, p: 0, r: 4, sh: 'ally', shield: 45 }
]});
defTech('t_staff_3', { name: 'Lotus Heart Sutra Staff', wt: 'staff', tier: 3, desc: 'Compassion with a hardwood edge.', moves: [
{ n: 'Lotus Burst', mp: 22, p: 152, r: 1, sh: 'area' },
{ n: 'Merciful Rain', mp: 30, p: 0, r: 4, sh: 'ally', healFlat: 120, stAlly: { k: 'regen', ch: 1, dur: 3 } }
]});
defTech('t_staff_4', { name: 'Patriarch\u2019s Cudgel', wt: 'staff', tier: 4, desc: 'Twenty-four founders hammered these forms into legend.', moves: [
{ n: 'Ocean Collapse', mp: 34, p: 192, r: 1, sh: 'area', cd: 2 },
{ n: 'World-Holding Palm', mp: 28, p: 0, r: 0, sh: 'self', shield: 90, healPct: 0.15, cd: 3 }
]});
defTech('t_fist_1', { name: 'Iron Fist Basics', wt: 'fist', tier: 1, desc: 'Two fists, four knuckles, no complaints.', moves: [
{ n: 'Straight Punch', mp: 8, p: 122, r: 1, sh: 'foe' },
{ n: 'Double Jab', mp: 12, p: 92, r: 1, sh: 'foe', hits: 2 }
]});
defTech('t_fist_2', { name: 'Tiger-Bone Boxing', wt: 'fist', tier: 2, desc: 'Guild boxing modeled on a hunting tiger.', moves: [
{ n: 'Tiger Claw', mp: 16, p: 148, r: 1, sh: 'foe', st: { k: 'bleed', ch: 0.5, dur: 2 } },
{ n: 'Roaring Tiger', mp: 18, p: 132, r: 1, sh: 'foe', buff: { stat: 'atk', pct: 0.35, dur: 2 } }
]});
defTech('t_fist_3', { name: 'Mountain-Cracking Palms', wt: 'fist', tier: 3, desc: 'Guild secret art. Palm-strikes like rockfalls.', moves: [
{ n: 'Crack Palm', mp: 24, p: 172, r: 1, sh: 'foe', cd: 1, st: { k: 'stun', ch: 0.3, dur: 1 } },
{ n: 'Palm Storm', mp: 38, p: 102, r: 1, sh: 'area', cd: 3 }
]});
defTech('t_fist_4', { name: 'Ten Thousand Rivers Palms', wt: 'fist', tier: 4, desc: 'Hong\u2019s beggar-art: ten thousand streams, one sea.', moves: [
{ n: 'River Turning Palm', mp: 26, p: 178, r: 2, sh: 'foe' },
{ n: 'Dragon Descends', mp: 52, p: 235, r: 1, sh: 'foe', cd: 4, st: { k: 'stun', ch: 0.5, dur: 1 } }
]});
defTech('t_hidden_1', { name: 'Dart Throwing Basics', wt: 'hidden', tier: 1, desc: 'Street tricks with sharpened steel.', moves: [
{ n: 'Flick', mp: 6, p: 100, r: 4, sh: 'foe' },
{ n: 'Dusted Dart', mp: 10, p: 95, r: 4, sh: 'foe', st: { k: 'poison', ch: 0.5, dur: 3 } }
]});
defTech('t_hidden_2', { name: 'Sleeved Needle Art', wt: 'hidden', tier: 2, desc: 'Valley needle-craft: numbing points, creeping venom.', moves: [
{ n: 'Needle Spray', mp: 14, p: 86, r: 4, sh: 'area', st: { k: 'poison', ch: 0.4, dur: 2 } },
{ n: 'Numbling Point', mp: 16, p: 112, r: 4, sh: 'foe', st: { k: 'slow', ch: 0.65, dur: 2 } }
]});
defTech('t_hidden_3', { name: 'Plum-Flower Darts', wt: 'hidden', tier: 3, desc: 'Five darts bloom like petals. Each petal bleeds.', moves: [
{ n: 'Plum Bloom', mp: 20, p: 122, r: 5, sh: 'foe', hits: 2, st: { k: 'bleed', ch: 0.5, dur: 2 }, cd: 1 },
{ n: 'Seven-Star Rain', mp: 34, p: 82, r: 5, sh: 'area', cd: 3, st: { k: 'poison', ch: 0.6, dur: 3 } }
]});
defTech('t_hidden_4', { name: 'Sanskrit Needle Canon', wt: 'hidden', tier: 4, desc: 'The valley\u2019s lost canon \u2014 silence, then sleep.', moves: [
{ n: 'Silent Requiem', mp: 30, p: 172, r: 5, sh: 'foe', cd: 2, st: { k: 'stun', ch: 0.4, dur: 1 } },
{ n: 'Sanskrit Rain', mp: 46, p: 96, r: 5, sh: 'area', cd: 3, st: { k: 'poison', ch: 1.0, dur: 3 } }
]});
/* ---------------- INTERNAL ARTS ---------------- */
const INTERNALS = {};
function defInt(id, o) { o.id = id; INTERNALS[id] = o; }
defInt('i_water', { name: 'Still Water Heart', desc: 'A calm basin of qi every wanderer starts with.', hp0: 10, hpL: 12, mp0: 10, mpL: 9, mpRegen: 2, active: { n: 'Clear Waters', desc: 'Cleanse all debuffs from yourself (CD 4)' }, cd: 4 });
defInt('i_azure', { name: 'Azure Cloud Canon', desc: 'Sword-shaped intent sharpens flesh and spirit.', hp0: 10, hpL: 10, atk0: 2, atkL: 2, critL: 1, spdL: 1, active: { n: 'Sword Qi Wave', desc: 'Cutting qi: 140% power, range 2 (CD 4)' }, cd: 4,
actMove: { n: 'Sword Qi Wave', mp: 15, p: 140, r: 2, sh: 'foe' } });
defInt('i_vajra', { name: 'Vajra Body Sutra', desc: 'Temple breathing that makes the body a bell of bronze.', hp0: 20, hpL: 20, def0: 2, defL: 2, active: { n: 'Golden Bell Dome', desc: 'Shield yourself (CD 5)' }, cd: 5,
actMove: { n: 'Golden Bell Dome', mp: 12, p: 0, r: 0, sh: 'self', shieldFlat: true } });
defInt('i_serpent', { name: 'Serpent Breath', desc: 'Your qi carries a venomous whisper.', hp0: 10, hpL: 12, atkL: 1, ptouch: true, active: { n: 'Venom Mist', desc: 'Poison all adjacent foes (CD 4)' }, cd: 4,
actMove: { n: 'Venom Mist', mp: 14, p: 60, r: 1, sh: 'cross', stAll: { k: 'poison', ch: 1.0, dur: 3 } } });
defInt('i_sun', { name: 'Blazing Sun Manual', desc: 'The lost manual. All rivers run to the sun.', hp0: 25, hpL: 16, mp0: 15, mpL: 10, atkL: 1, defL: 1, spdL: 1, regenHp: true, active: { n: 'Solar Flare', desc: 'Scorching wave: 165% power, range 2 line (CD 5)' }, cd: 5,
actMove: { n: 'Solar Flare', mp: 20, p: 165, r: 2, sh: 'line' } });
/* ---------------- LIGHTNESS ---------------- */
const LIGHTNESS = {
l_basic: { name: 'Goose Steps', desc: 'Long strides learned from chasing chickens.', mv: 1, dodge: 3 },
l_cloud: { name: 'Cloud-Chasing Steps', desc: 'Courier footwork that eats roads whole.', mv: 2, dodge: 6, spd: 1 },
l_shadow: { name: 'Phantom Shadow Trace', desc: 'You move when eyes blink.', mv: 3, dodge: 10, spd: 2 }
};
/* ---------------- ENEMIES ---------------- */
const ENEMIES = {};
function defEnemy(id, o) { o.id = id; ENEMIES[id] = o; }
defEnemy('e_wolf', { name: 'Grey Wolf', tier: 1, hp: 46, atk: 10, def: 2, spd: 9, mv: 5, rng: 1, silver: [4, 10], exp: 22, loot: [{ id: 'mat_pelt', ch: 0.55, q: 1 }, { id: 'mat_fang', ch: 0.4, q: 1 }], ai: 'brute', glyph: '狼', color: '#7d8471',
moves: [{ n: 'Bite', mp: 0, p: 112, r: 1, sh: 'foe' }, { n: 'Rending Pull', mp: 6, p: 118, r: 1, sh: 'foe', st: { k: 'bleed', ch: 0.4, dur: 2 } }] });
defEnemy('e_boar', { name: 'Tusker Boar', tier: 1, hp: 62, atk: 11, def: 5, spd: 6, mv: 4, rng: 1, silver: [5, 12], exp: 24, loot: [], ai: 'brute', glyph: '猪', color: '#8a7360',
moves: [{ n: 'Tusk Gore', mp: 0, p: 125, r: 1, sh: 'foe', st: { k: 'stun', ch: 0.2, dur: 1 } }] });
defEnemy('e_snake', { name: 'Green Pit Viper', tier: 1, hp: 38, atk: 9, def: 2, spd: 11, mv: 6, rng: 1, silver: [6, 14], exp: 23, loot: [{ id: 'mat_gall', ch: 0.5, q: 1 }], ai: 'skirmish', glyph: '蛇', color: '#6fa06b',
moves: [{ n: 'Venom Fang', mp: 0, p: 104, r: 1, sh: 'foe', st: { k: 'poison', ch: 0.6, dur: 3 } }] });
defEnemy('e_tiger', { name: 'Cloudmist Tiger', tier: 2, hp: 88, atk: 17, def: 5, spd: 12, mv: 6, rng: 1, silver: [15, 30], exp: 48, loot: [{ id: 'mat_pelt', ch: 0.8, q: 2 }], ai: 'brute', glyph: '虎', color: '#c78d3f',
moves: [{ n: 'Pounce', mp: 8, p: 142, r: 1, sh: 'foe', st: { k: 'stun', ch: 0.3, dur: 1 } }, { n: 'Rake', mp: 6, p: 124, r: 1, sh: 'foe', st: { k: 'bleed', ch: 0.5, dur: 2 } }] });
defEnemy('e_bear', { name: 'Ridge Bear', tier: 3, hp: 135, atk: 22, def: 9, spd: 7, mv: 4, rng: 1, silver: [25, 45], exp: 85, loot: [{ id: 'c_herb_red', ch: 0.3, q: 1 }], ai: 'brute', glyph: '熊', color: '#7a5c44',
moves: [{ n: 'Maul', mp: 10, p: 158, r: 1, sh: 'foe' }, { n: 'Terrifying Roar', mp: 12, p: 0, r: 0, sh: 'self', buff: { stat: 'atk', pct: 0.3, dur: 3 } }] });
defEnemy('e_bandit', { name: 'Road Bandit', tier: 1, hp: 55, atk: 11, def: 3, spd: 8, mv: 4, rng: 1, silver: [12, 22], exp: 25, loot: [{ id: 'c_bun', ch: 0.3, q: 1 }], ai: 'brute', glyph: '匪', color: '#96604a',
moves: [{ n: 'Slash', mp: 0, p: 120, r: 1, sh: 'foe' }] });
defEnemy('e_bandit_vet', { name: 'Bandit Veteran', tier: 2, hp: 82, atk: 16, def: 6, spd: 9, mv: 5, rng: 1, silver: [22, 40], exp: 50, loot: [{ id: 'c_wine', ch: 0.25, q: 1 }, { id: 'mat_ore_1', ch: 0.3, q: 1 }], ai: 'brute', glyph: '寇', color: '#8f4d3a',
moves: [{ n: 'Heavy Slash', mp: 8, p: 145, r: 1, sh: 'foe' }, { n: 'Hurl Dagger', mp: 6, p: 100, r: 3, sh: 'foe' }] });
defEnemy('e_bandit_chief', { name: 'Bandit Chief', tier: 3, hp: 130, atk: 21, def: 8, spd: 10, mv: 5, rng: 1, silver: [45, 75], exp: 105, loot: [{ id: 'c_wine', ch: 0.5, q: 1 }, { id: 'mat_ore_1', ch: 0.5, q: 2 }], ai: 'boss', glyph: '魁', color: '#a05c46',
moves: [{ n: 'Cleaving Chop', mp: 12, p: 168, r: 1, sh: 'cross' }, { n: 'Brute Rush', mp: 10, p: 148, r: 1, sh: 'foe', st: { k: 'bleed', ch: 0.5, dur: 2 } }, { n: 'Warlord\u2019s Bellow', mp: 14, p: 0, r: 0, sh: 'self', buff: { stat: 'atk', pct: 0.3, dur: 3 }, cd: 3 }] });
defEnemy('e_thug', { name: 'Wharf Thug', tier: 1, hp: 52, atk: 10, def: 4, spd: 7, mv: 4, rng: 1, silver: [10, 18], exp: 24, loot: [{ id: 'c_bun', ch: 0.4, q: 1 }], ai: 'brute', glyph: '霸', color: '#77685a',
moves: [{ n: 'Haymaker', mp: 6, p: 128, r: 1, sh: 'foe', st: { k: 'stun', ch: 0.25, dur: 1 } }] });
defEnemy('e_guard', { name: 'Garrison Halberdier', tier: 2, hp: 92, atk: 15, def: 10, spd: 8, mv: 4, rng: 2, silver: [20, 35], exp: 52, loot: [], ai: 'guard', glyph: '兵', color: '#5f7d94',
moves: [{ n: 'Halberd Thrust', mp: 8, p: 140, r: 2, sh: 'foe' }, { n: 'Shield Wall', mp: 10, p: 0, r: 0, sh: 'self', shield: 40, buff: { stat: 'def', pct: 0.25, dur: 2 } }] });
defEnemy('e_assassin', { name: 'Umbrella Shadow', tier: 3, hp: 96, atk: 24, def: 5, spd: 14, mv: 7, rng: 1, silver: [35, 60], exp: 95, loot: [{ id: 'c_venom', ch: 0.25, q: 1 }], ai: 'skirmish', glyph: '影', color: '#5a5468',
moves: [{ n: 'Backstab', mp: 10, p: 175, r: 1, sh: 'foe' }, { n: 'Poison Needle', mp: 8, p: 105, r: 4, sh: 'foe', st: { k: 'poison', ch: 0.7, dur: 3 } }] });
defEnemy('e_cultist', { name: 'Umbrella Adept', tier: 2, hp: 72, atk: 14, def: 5, spd: 9, mv: 4, rng: 1, silver: [25, 45], exp: 55, loot: [{ id: 'mat_ghost_ink', ch: 0.15, q: 1 }], ai: 'caster', glyph: '伞', color: '#6b5f7d',
moves: [{ n: 'Dark Palm', mp: 8, p: 130, r: 1, sh: 'foe' }, { n: 'Withering Curse', mp: 12, p: 90, r: 3, sh: 'foe', st: { k: 'slow', ch: 0.7, dur: 2 } }] });
defEnemy('e_poisoner', { name: 'Valley Renegade', tier: 3, hp: 85, atk: 18, def: 6, spd: 11, mv: 5, rng: 3, silver: [40, 65], exp: 98, loot: [{ id: 'mat_gall', ch: 0.4, q: 1 }, { id: 'c_antidote', ch: 0.3, q: 1 }], ai: 'archer', glyph: '毒', color: '#71905d',
moves: [{ n: 'Venom Spray', mp: 14, p: 95, r: 3, sh: 'area', st: { k: 'poison', ch: 0.6, dur: 3 }, cd: 2 }, { n: 'Dart', mp: 0, p: 115, r: 3, sh: 'foe' }] });
defEnemy('e_duelist', { name: 'Wandering Duelist', tier: 4, hp: 152, atk: 28, def: 11, spd: 13, mv: 6, rng: 1, silver: [60, 100], exp: 150, loot: [{ id: 'c_pill_hp', ch: 0.3, q: 1 }, { id: 'ac_scarf', ch: 0.1, q: 1 }], ai: 'duelist', glyph: '侠', color: '#a8895c',
moves: [{ n: 'Flowing Combo', mp: 14, p: 96, r: 1, sh: 'foe', hits: 2 }, { n: 'Riposte Stance', mp: 12, p: 0, r: 0, sh: 'self', buff: { stat: 'atk', pct: 0.3, dur: 2 }, shield: 30 }] });
defEnemy('e_sentinel', { name: 'Tomb Sentinel', tier: 4, hp: 185, atk: 26, def: 15, spd: 6, mv: 4, rng: 1, silver: [50, 80], exp: 160, loot: [{ id: 'mat_ore_2', ch: 0.5, q: 2 }, { id: 'mat_ore_3', ch: 0.15, q: 1 }], ai: 'brute', glyph: '卫', color: '#8c8776',
moves: [{ n: 'Stone Smash', mp: 10, p: 168, r: 1, sh: 'foe', st: { k: 'stun', ch: 0.3, dur: 1 } }, { n: 'Grave Quake', mp: 16, p: 120, r: 1, sh: 'area', cd: 2 }] });
defEnemy('e_boss_qiang', { name: '"One-Eye" Qiang', tier: 3, hp: 240, atk: 24, def: 9, spd: 10, mv: 5, rng: 1, silver: [120, 180], exp: 220, loot: [{ id: 'mat_token', ch: 1, q: 1 }, { id: 'w_blade_2', ch: 0.5, q: 1 }], ai: 'boss', glyph: '枭', color: '#9c4a3f', boss: true,
moves: [{ n: 'Cleaving Chop', mp: 12, p: 172, r: 1, sh: 'cross' }, { n: 'Pillage Rush', mp: 10, p: 150, r: 1, sh: 'foe', st: { k: 'bleed', ch: 0.6, dur: 2 } }, { n: 'Warlord\u2019s Bellow', mp: 14, p: 0, r: 0, sh: 'self', buff: { stat: 'atk', pct: 0.3, dur: 3 }, cd: 3 }] });
defEnemy('e_boss_dukang', { name: 'Du Kang, the Silver Umbrella', tier: 4, hp: 320, atk: 32, def: 12, spd: 13, mv: 6, rng: 1, silver: [220, 300], exp: 380, loot: [{ id: 'ac_dragonring', ch: 1, q: 1 }], ai: 'boss', glyph: '银', color: '#b0a08c', boss: true,
moves: [{ n: 'Venom Umbrella Spin', mp: 18, p: 130, r: 1, sh: 'area', st: { k: 'poison', ch: 0.7, dur: 3 }, cd: 2 }, { n: 'Triple Silver Thrust', mp: 16, p: 92, r: 1, sh: 'foe', hits: 3 }, { n: 'Silken Retreat', mp: 12, p: 0, r: 0, sh: 'self', shield: 60, buff: { stat: 'spd', pct: 0.3, dur: 2 }, cd: 3 }] });
defEnemy('e_boss_heihu', { name: 'Heihu, Umbrella Enforcer', tier: 4, hp: 360, atk: 33, def: 14, spd: 12, mv: 5, rng: 1, silver: [260, 340], exp: 430, loot: [{ id: 'mat_ore_3', ch: 0.6, q: 1 }, { id: 'c_pill_hp', ch: 1, q: 2 }], ai: 'boss', glyph: '黑', color: '#5c5560', boss: true,
moves: [{ n: 'Umbrella Sweep', mp: 16, p: 185, r: 1, sh: 'cross', cd: 1 }, { n: 'Shadow Slam', mp: 14, p: 165, r: 1, sh: 'foe', st: { k: 'stun', ch: 0.35, dur: 1 } }, { n: 'Iron Canopy', mp: 18, p: 0, r: 0, sh: 'self', shield: 90, buff: { stat: 'def', pct: 0.3, dur: 2 }, cd: 4 }] });
defEnemy('e_boss_wu', { name: 'Wu Zhaoshan, Patriarch of the Iron Umbrella', tier: 5, hp: 620, atk: 42, def: 16, spd: 14, mv: 6, rng: 1, silver: [600, 900], exp: 900, loot: [{ id: 'w_sword_4', ch: 1, q: 1 }], ai: 'boss', glyph: '尊', color: '#8f6f4f', boss: true,
moves: [{ n: 'Heaven Splitter', mp: 22, p: 215, r: 1, sh: 'line', cd: 2 }, { n: 'Umbrella Domain', mp: 26, p: 135, r: 1, sh: 'area', cd: 2, st: { k: 'slow', ch: 0.6, dur: 2 } }, { n: 'Ten Thousand Oaths', mp: 20, p: 100, r: 1, sh: 'foe', hits: 3 }, { n: 'Ascendant Fury', mp: 18, p: 0, r: 0, sh: 'self', buff: { stat: 'atk', pct: 0.4, dur: 3 }, cd: 4 }] });
/* ---- Tier V ultimates & support arts ---- */
defTech('t_sword_5', { name: 'River of No Return', wt: 'sword', tier: 5, desc: 'Yun\u2019s final teaching: the stroke that cannot be recalled.', moves: [
{ n: 'Reversal Cut', mp: 26, p: 182, r: 1, sh: 'foe' },
{ n: 'Ten-Thousand Ripples', mp: 38, p: 148, r: 1, sh: 'cross', st: { k: 'bleed', ch: 0.5, dur: 3 } }
]});
defTech('t_blade_5', { name: 'Mountain-Cleaving Dao', wt: 'blade', tier: 5, desc: 'One stroke to split what should not be split.', moves: [
{ n: 'Cleaving Stroke', mp: 30, p: 192, r: 1, sh: 'foe' },
{ n: 'Earthsplitter', mp: 40, p: 160, r: 1, sh: 'line', st: { k: 'bleed', ch: 0.6, dur: 3 } }
]});
defTech('t_spear_5', { name: 'Dragon Rends the Heavens', wt: 'spear', tier: 5, desc: 'The garrison\u2019s lost apex art, recovered at great cost.', moves: [
{ n: 'Heaven Thrust', mp: 28, p: 178, r: 2, sh: 'foe', st: { k: 'stun', ch: 0.3, dur: 1 } },
{ n: 'Storm Coil', mp: 36, p: 142, r: 2, sh: 'line' }
]});
defTech('t_staff_5', { name: 'Demon-Banishing Chant', wt: 'staff', tier: 5, desc: 'Sutra and ironwood in the same breath.', moves: [
{ n: 'Banishing Strike', mp: 28, p: 170, r: 1, sh: 'foe' },
{ n: 'Lotus Aegis', mp: 24, p: 0, r: 0, sh: 'self', buff: { stat: 'def', pct: 0.45, dur: 3 } }
]});
defTech('t_fist_5', { name: 'Thousand-Palm Storm', wt: 'fist', tier: 5, desc: 'Guo\u2019s last lesson: there is always one more palm.', moves: [
{ n: 'Storm Volley', mp: 27, p: 196, r: 1, sh: 'foe' },
{ n: 'Shaking Palm', mp: 38, p: 150, r: 1, sh: 'foe', st: { k: 'stun', ch: 0.35, dur: 1 } }
]});
defTech('t_hidden_5', { name: 'Silent Stars Fall', wt: 'hidden', tier: 5, desc: 'Miao\u2019s parting gift: night made sharp.', moves: [
{ n: 'Starfall Volley', mp: 26, p: 168, r: 4, sh: 'foe', st: { k: 'poison', ch: 0.7, dur: 3 } },
{ n: 'Needle Bloom', mp: 34, p: 138, r: 4, sh: 'cross' }
]});
defTech('t_univ_2', { name: 'Mending Sutra', wt: 'universal', tier: 3, desc: 'Temple healing every wanderer should know.', moves: [
{ n: 'Mending Breeze', mp: 18, r: 1, sh: 'ally', healFlat: 110 },
{ n: 'Cool Rain', mp: 26, r: 1, sh: 'ally', healFlat: 60, st: { k: 'regen', ch: 1, dur: 3 } }
]});
defTech('t_univ_3', { name: 'War God\u2019s Oath', wt: 'universal', tier: 4, desc: 'Guild-hall discipline turned inward.', moves: [
{ n: 'Rallying Cry', mp: 22, p: 0, r: 0, sh: 'self', buff: { stat: 'atk', pct: 0.35, dur: 3 } },
{ n: 'Iron Stance', mp: 24, p: 0, r: 0, sh: 'self', buff: { stat: 'def', pct: 0.4, dur: 3 } }
]});
/* manuals for the above */
defItem('m_t_sword_5', { name: 'Manual: River of No Return', type: 'manual', teach: 't_sword_5', price: 3200, rar: 4, desc: 'Watercolors of an endless river.' });
defItem('m_t_blade_5', { name: 'Manual: Mountain-Cleaving Dao', type: 'manual', teach: 't_blade_5', price: 3000, rar: 4, desc: 'The margins are full of axe marks.' });
defItem('m_t_spear_5', { name: 'Manual: Dragon Rends the Heavens', type: 'manual', teach: 't_spear_5', price: 3100, rar: 4, desc: 'Smells faintly of gunpowder and rain.' });
defItem('m_t_staff_5', { name: 'Manual: Demon-Banishing Chant', type: 'manual', teach: 't_staff_5', price: 3000, rar: 4, desc: 'Every page hums when read aloud.' });
defItem('m_t_fist_5', { name: 'Manual: Thousand-Palm Storm', type: 'manual', teach: 't_fist_5', price: 3000, rar: 4, desc: 'Thumb-worn to silk at the corners.' });
defItem('m_t_hidden_5', { name: 'Manual: Silent Stars Fall', type: 'manual', teach: 't_hidden_5', price: 2900, rar: 4, desc: 'Ink only visible by moonlight.' });
defItem('m_univ_2', { name: 'Manual: Mending Sutra', type: 'manual', teach: 't_univ_2', price: 1400, rar: 3, desc: 'A healer\u2019s careful hand on every page.' });
defItem('m_univ_3', { name: 'Manual: War God\u2019s Oath', type: 'manual', teach: 't_univ_3', price: 1900, rar: 3, desc: 'Sweat-stained guild primer.' });
/* mythic-tier armor */
defItem('ar_head_5', { name: 'Phoenix Feather Crown', type: 'armor', slot: 'head', def: 15, hp: 40, crit: 6, price: 2400, rar: 4, desc: 'One feather, they say, from the last phoenix.' });
defItem('ar_body_5', { name: 'Dragon-Silk Vestment', type: 'armor', slot: 'body', def: 21, hp: 70, spd: 2, price: 3400, rar: 4, desc: 'Woven from a single unraveled scale.' });
defItem('ar_foot_5', { name: 'Cloud-Treading Greaves', type: 'armor', slot: 'feet', def: 10, spd: 7, dodge: 8, price: 2600, rar: 4, desc: 'Leave them by a cliff overnight; by dawn, clouds.' });
+339
View File
@@ -0,0 +1,339 @@
'use strict';
/* ============================================================
DATA: NPCs, world map, quests, origins, recipes, tournament
All names & lore are original fiction.
============================================================ */
/* ---------------- NPCs ---------------- */
const NPCS = {};
function defNpc(id, o) { o.id = id; NPCS[id] = o; }
defNpc('n_elderchen', { name: 'Elder Chen', title: 'Village Elder', loc: 'l_village', gender: 'm', hue: '#c9b28a', char: '陈', role: 'Keeps the peace and worries about everyone', dlg: 'd_elder', giftTags: ['kind'] });
defNpc('n_innkeeper_ma', { name: 'Innkeeper Ma', title: 'Willow Creek Inn', loc: 'l_village', gender: 'f', hue: '#b58f7a', char: '马', role: 'Hot beds, hotter congee', dlg: 'd_inn_ma', inn: 8, giftTags: ['kind'] });
defNpc('n_kid_xiaoli', { name: 'Xiaoli', title: 'Village Kid', loc: 'l_village', gender: 'f', hue: '#d4b84e', char: '李', role: 'Knows every rumor worth half a candy', dlg: 'd_kid', giftTags: ['youth', 'kind'] });
defNpc('n_merchant_su', { name: 'Merchant Su', title: 'Su & Sons Trading', loc: 'l_city', gender: 'm', hue: '#8fae6f', char: '苏', role: 'General goods, fair prices, better gossip', dlg: 'd_su', shop: { name: 'General Goods', stock: ['c_bun', 'c_herb_green', 'c_herb_red', 'ar_body_1', 'ar_body_2', 'ar_head_1', 'ar_foot_1', 'ar_foot_2', 'w_sword_1', 'w_blade_1', 'w_spear_1', 'w_staff_1', 'w_fist_1', 'w_hidden_1', 'gift_tea', 'gift_cakes', 'ac_jade'] }, giftTags: ['noble'] });
defNpc('n_innkeeper_zhou', { name: 'Innkeeper Zhou', title: 'Golden Carp Inn', loc: 'l_city', gender: 'm', hue: '#caa25f', char: '周', role: 'Runs the finest inn between two mountains', dlg: 'd_inn_zhou', inn: 14, giftTags: ['drinker'] });
defNpc('n_captain_yan', { name: 'Captain Yan', title: 'Garrison Liaison Office', loc: 'l_city', gender: 'f', hue: '#7d93ad', char: '燕', role: 'Sharp uniform, sharper tongue', dlg: 'd_yan', giftTags: ['scholar'] });
defNpc('n_arena_qian', { name: 'Host Qian', title: 'Martial Tournament Grounds', loc: 'l_city', gender: 'm', hue: '#c07a52', char: '钱', role: 'Announcer, bookie, friend of heroes', dlg: 'd_qian', giftTags: ['noble'] });
defNpc('n_beggar_hong', { name: 'Hong the Beggar', title: 'Wherever the wine is', loc: 'l_city', gender: 'm', hue: '#9c8f76', char: '洪', role: 'Drunk, ridiculous, occasionally terrifying', dlg: 'd_hong', spar: true, hiddenNpc: true, giftTags: ['drinker', 'kind'] });
defNpc('n_smith_wang', { name: 'Smith Wang', title: 'Ember Forge', loc: 'l_town', gender: 'm', hue: '#a5643f', char: '王', role: 'Forges blades and opinions equally hot', dlg: 'd_wang', smith: true, shop: { name: 'Forge Goods', stock: ['w_sword_2', 'w_blade_2', 'w_spear_2', 'w_staff_2', 'w_fist_2', 'w_hidden_2', 'ar_body_2', 'ar_body_3', 'ar_head_2', 'ar_foot_2', 'ar_foot_3', 'ac_bracer', 'mat_ore_1'] }, giftTags: ['fighter'] });
defNpc('n_alchemist_bai', { name: 'Alchemist Bai', title: 'Bai\u2019s Remedies', loc: 'l_town', gender: 'f', hue: '#9dbb74', char: '白', role: 'If it can be brewed, she has brewed it', dlg: 'd_bai', alchemy: true, shop: { name: 'Remedies', stock: ['c_pill_hp', 'c_pill_mp', 'c_antidote', 'c_herb_red', 'c_lotus', 'gift_tea'] }, giftTags: ['scholar'] });
defNpc('n_innkeeper_lu', { name: 'Innkeeper Lu', title: 'Ember Lodge', loc: 'l_town', gender: 'f', hue: '#bd9a6a', char: '卢', role: 'Feeds smiths, miners, and occasional heroes', dlg: 'd_inn_lu', inn: 10, giftTags: ['kind'] });
defNpc('n_hunter_ren', { name: 'Hunter Ren', title: 'Bamboo Edge Cabin', loc: 'l_bamboo', gender: 'm', hue: '#7f9663', char: '任', role: 'Traps wolves, trades pelts, mistrusts cities', dlg: 'd_ren', spar: true, teach: [{ tech: 't_blade_1', aff: 25, cost: 0, note: 'free for trusted friends' }], giftTags: ['fighter', 'drinker'] });
defNpc('n_tiehu', { name: 'Zhao Tiehu', title: 'Dockside Challenger', loc: 'l_dock', gender: 'm', hue: '#bf7d4f', char: '赵', role: 'Undefeated arm-wrestler of Peach Blossom Dock', dlg: 'd_tiehu', spar: true, companion: { wt: 'fist', techs: ['t_fist_1', 't_fist_2'], internal: 'i_water', hpM: 1.25, atkM: 0.95, defM: 1.15 }, giftTags: ['fighter', 'drinker'] });
defNpc('n_ferryman', { name: 'Old Gao the Ferryman', title: 'Peach Blossom Dock', loc: 'l_dock', gender: 'm', hue: '#8d9aa5', char: '高', role: 'Rows at night to a shore not on maps', dlg: 'd_ferry', giftTags: ['drinker'] });
defNpc('n_abbot_lian', { name: 'Abbot Lian', title: 'Pure Lotus Temple', loc: 'l_temple', gender: 'm', hue: '#c8b46a', char: '莲', role: 'Sweeps leaves, teaches sutras, sees everything', dlg: 'd_abbot',
teach: [
{ tech: 't_staff_2', aff: 20, cost: 300 },
{ tech: 't_staff_3', aff: 55, cost: 900, reqRep: ['temple', 20] },
{ internal: 'i_vajra', aff: 40, cost: 500 },
{ internal: 'i_sun', aff: 90, cost: 2000, reqFlag: 'sunHint' }
], giftTags: ['monk', 'scholar'] });
defNpc('n_monk_jing', { name: 'Monk Jing', title: 'Pure Lotus Temple', loc: 'l_temple', gender: 'm', hue: '#a8a06b', char: '静', role: 'Training-yard partner with infinite patience', dlg: 'd_jing', spar: true, giftTags: ['monk'] });
defNpc('n_master_yun', { name: 'Master Yun', title: 'Azure Cloud Sect', loc: 'l_sect', gender: 'f', hue: '#7d9bb8', char: '云', role: 'First Blade of Azure Cloud Peak', dlg: 'd_yun',
teach: [
{ tech: 't_sword_2', aff: 20, cost: 300 },
{ tech: 't_sword_3', aff: 55, cost: 900, reqRep: ['azure', 20] },
{ internal: 'i_azure', aff: 40, cost: 500 }
], giftTags: ['scholar'] });
defNpc('n_lin', { name: 'Lin Wan\u2019er', title: 'Azure Cloud Disciple', loc: 'l_sect', gender: 'f', hue: '#b8797d', char: '林', role: 'Sword prodigy, chronic winner, terrible loser', dlg: 'd_lin', spar: true, romanceable: true,
companion: { wt: 'sword', techs: ['t_sword_1', 't_sword_2'], internal: 'i_water', hpM: 1.0, atkM: 1.1, defM: 0.95 }, giftTags: ['fighter', 'romance'] });
defNpc('n_lady_miao', { name: 'Lady Miao', title: 'Serpent Veil Valley', loc: 'l_valley', gender: 'f', hue: '#87a06b', char: '苗', role: 'Smiles like honey, seasons everything with venom', dlg: 'd_miao',
teach: [
{ tech: 't_hidden_2', aff: 15, cost: 250 },
{ tech: 't_hidden_3', aff: 50, cost: 850, reqRep: ['serpent', 20] },
{ internal: 'i_serpent', aff: 35, cost: 450 }
], giftTags: ['noble'] });
defNpc('n_suqing', { name: 'Su Qingluo', title: 'Valley Physician', loc: 'l_valley', gender: 'f', hue: '#a5b879', char: '青', role: 'Cures what ails you; causes what cures you', dlg: 'd_suqing', romanceable: true,
companion: { wt: 'hidden', techs: ['t_hidden_1', 't_hidden_2'], internal: 'i_water', ai: 'support', hpM: 0.9, atkM: 1.0, defM: 0.95 }, giftTags: ['scholar', 'romance'] });
defNpc('n_master_guo', { name: 'Master Guo', title: 'Iron Fist Guild Hall', loc: 'l_garrison', gender: 'm', hue: '#b3703f', char: '郭', role: 'Retired pit-fighter running the guild hall', dlg: 'd_guo', spar: true,
teach: [
{ tech: 't_fist_2', aff: 20, cost: 300 },
{ tech: 't_fist_3', aff: 55, cost: 900, reqRep: ['fist', 20] }
], giftTags: ['fighter', 'drinker'] });
defNpc('n_drill_capt', { name: 'Drill-Captain Pei', title: 'Black Tortoise Garrison', loc: 'l_garrison', gender: 'm', hue: '#6f8296', char: '裴', role: 'Runs drills, bounty boards, and recruits ragged', dlg: 'd_pei', giftTags: ['fighter'] });
defNpc('n_broker_qiu', { name: 'Broker Qiu', title: 'Ghost Market', loc: 'l_market', gender: 'm', hue: '#9a86a8', char: '邱', role: 'Sells secrets by the ounce', dlg: 'd_broker', giftTags: ['scholar', 'noble'] });
defNpc('n_fence_wu', { name: 'Fence Wu', title: 'Ghost Market', loc: 'l_market', gender: 'f', hue: '#8a7d9c', char: '吴', role: 'Everything has a previous owner', dlg: 'd_fence', shop: { name: 'Acquisitions', stock: ['m_l_cloud', 'm_l_shadow', 'm_t_sword_2', 'm_t_blade_2', 'm_t_spear_2', 'm_t_staff_2', 'm_t_fist_2', 'm_t_hidden_2', 'ac_goldbell', 'c_fragment', 'c_elixir_power', 'c_elixir_swift', 'gift_silk', 'gift_book', 'gift_hairpin'] }, giftTags: ['noble'] });
/* ---------------- WORLD ---------------- */
const LOCS = {};
function defLoc(id, o) { o.id = id; LOCS[id] = o; }
defLoc('l_village', { name: 'Willow Creek Village', desc: 'Your home. Willows lean over the creek; chickens own the road.', danger: 0, region: 'Heartlands',
actions: [{ id: 'herb', label: 'Gather herbs', note: '1h · daily' }],
npcs: ['n_elderchen', 'n_innkeeper_ma', 'n_kid_xiaoli'] });
defLoc('l_city', { name: 'Qingyun City', desc: 'Ten thousand roofs, one hundred rumors, at least nine true.', danger: 0, region: 'Heartlands',
actions: [{ id: 'board', label: 'Bulletin Board', note: 'bounties & notices' }, { id: 'gamble', label: 'Dice den', note: 'moderate stakes' }, { id: 'pickpocket', label: 'Work the crowd', note: 'risky · daily' }],
npcs: ['n_merchant_su', 'n_innkeeper_zhou', 'n_captain_yan', 'n_arena_qian', 'n_beggar_hong'] });
defLoc('l_town', { name: 'Ember Town', desc: 'Coal smoke, ringing anvils, and the best noodles in the prefecture.', danger: 0, region: 'Heartlands',
npcs: ['n_smith_wang', 'n_alchemist_bai', 'n_innkeeper_lu'] });
defLoc('l_dock', { name: 'Peach Blossom Dock', desc: 'Petals on black water; barges going everywhere and nowhere.', danger: 0, region: 'Riverlands',
actions: [{ id: 'pickpocket', label: 'Work the crowd', note: 'risky · daily' }, { id: 'fish', label: 'Fish off the pier', note: 'timing · ×10 daily' }],
npcs: ['n_tiehu', 'n_ferryman'] });
defLoc('l_garrison', { name: 'Black Tortoise Garrison', desc: 'Banners, drill-yards, and the Empire\u2019s long memory.', danger: 0, region: 'Heartlands',
actions: [{ id: 'board', label: 'Military Bounty Board', note: 'better-paying bounties' }],
npcs: ['n_master_guo', 'n_drill_capt'] });
defLoc('l_bamboo', { name: 'Verdant Bamboo Sea', desc: 'Green light falls in stripes. Something is always watching.', danger: 2, region: 'Wilds',
pools: { low: ['e_wolf', 'e_snake'], mid: ['e_wolf', 'e_tiger', 'e_bandit'], high: ['e_tiger', 'e_bandit_vet'] },
actions: [{ id: 'herb', label: 'Gather herbs', note: '1h · daily' }, { id: 'camp', label: 'Make camp', note: '6h · restore 55%' }],
npcs: ['n_hunter_ren'] });
defLoc('l_mountain', { name: 'Cloudmist Ridge', desc: 'The pass winds through fog, old shrines, and newer ambushes.', danger: 2, region: 'Wilds',
pools: { low: ['e_wolf', 'e_boar'], mid: ['e_wolf', 'e_tiger', 'e_bandit'], high: ['e_tiger', 'e_bandit_vet', 'e_bear'] },
actions: [{ id: 'mine', label: 'Prospect for ore', note: '2h · daily' }, { id: 'camp', label: 'Make camp', note: '6h · restore 55%' }],
npcs: [] });
defLoc('l_valley', { name: 'Serpent Veil Valley', desc: 'Beautiful, fragrant, and quietly lethal. Mind where you step.', danger: 3, region: 'Deep Wilds',
pools: { mid: ['e_snake', 'e_poisoner'], high: ['e_poisoner', 'e_tiger', 'e_assassin'] },
actions: [{ id: 'herb', label: 'Gather rare herbs', note: '1h · daily' }, { id: 'camp', label: 'Make camp', note: '6h · restore 55%' }],
npcs: ['n_lady_miao', 'n_suqing'] });
defLoc('l_temple', { name: 'Pure Lotus Temple', desc: 'Incense, bells, and a training yard swept to mirror-brightness.', danger: 0, region: 'Highlands',
actions: [{ id: 'meditate', label: 'Meditate in the hall', note: '2h · cultivates internal arts' }, { id: 'dummy', label: 'Train at the posts', note: '1h · practice a technique' }],
npcs: ['n_abbot_lian', 'n_monk_jing'] });
defLoc('l_sect', { name: 'Azure Cloud Peak', desc: 'Stone stairs above the cloud-line; swords sing at dawn.', danger: 0, region: 'Highlands',
actions: [{ id: 'meditate', label: 'Meditate on the terrace', note: '2h' }, { id: 'dummy', label: 'Train at the posts', note: '1h · practice a technique' }, { id: 'secttasks', label: 'Mission Hall', note: 'tasks for sect reputation' }],
npcs: ['n_master_yun', 'n_lin'] });
defLoc('l_market', { name: 'Ghost Market', desc: 'A market with no daylight and no questions. Entry after dusk only.', danger: 1, region: 'Underworld', nightOnly: true,
actions: [{ id: 'gamble', label: 'Dice den', note: 'high stakes' }, { id: 'pickpocket', label: 'Lift a purse', note: 'very risky' }],
npcs: ['n_broker_qiu', 'n_fence_wu'] });
defLoc('l_tomb', { name: 'Ancient Tomb of the First Sword', desc: 'Buried stairs, patient dead things, and a door that remembers.', danger: 4, region: 'Deep Wilds',
pools: { high: ['e_sentinel', 'e_assassin'] },
npcs: [] });
const TRAVEL = [
['l_village', 'l_mountain', 2],
['l_village', 'l_city', 4],
['l_city', 'l_town', 2],
['l_city', 'l_dock', 1],
['l_city', 'l_garrison', 2],
['l_city', 'l_bamboo', 3],
['l_dock', 'l_market', 1],
['l_bamboo', 'l_sect', 2],
['l_bamboo', 'l_valley', 3],
['l_town', 'l_temple', 3],
['l_mountain', 'l_tomb', 3]
];
/* ---------------- QUESTS ---------------- */
const QUESTS = {
q_main_1: { name: 'Wolf Trouble', main: true, stages: [
'Speak with Elder Chen.',
'Cull the wolf packs on Cloudmist Ridge. (%K/wolves)',
'Report back to Elder Chen.'
],
doneText: 'The flocks sleep easy — for now.' },
q_main_2: { name: 'A Trail Leads to Qingyun', main: true, stages: [
'Find Captain Yan at the Garrison Liaison Office in Qingyun City.',
'Destroy the bandit camp on Cloudmist Ridge.',
'Defeat "One-Eye" Qiang.',
'Search the camp for evidence.'
],
doneText: 'Qiangs lead token now sits in Captain Yans evidence box.' },
q_main_3: { name: 'The Iron Umbrella', main: true, stages: [
'Learn about the Iron Umbrella Society. (Try the Ghost Market at night.)',
'Buy Broker Qiu\u2019s intelligence on the tournament plot.',
'Win through to the tournament finals in Qingyun City.',
'Expose the impostor among the finalists.'
],
doneText: 'Du Kang escaped with his life — but not his secrets.' },
q_main_4: { name: 'Gathering Storm', main: true, stages: [
'Recruit at least two companions to your banner.',
'Join a school or earn Garrison trust (rep 30).',
'Report to Captain Yan.'
],
doneText: 'You are no longer alone on the road.' },
q_main_5: { name: 'The Heaven\u2019s Ledger', main: true, stages: [
'Take the mountain path to the Ancient Tomb of the First Sword.',
'Silence two Tomb Sentinels guarding the descent. (%K/sentinels)',
'Reach the inner door before the Iron Umbrella takes the Ledger.',
'Claim the Heaven\u2019s Ledger.'
],
doneText: 'The Ledger weighs almost nothing, and changes everything.' },
q_main_6: { name: 'Showdown at Azure Peak', main: true, stages: [
'The Iron Umbrella marches on Azure Cloud Peak. Go there and hold the line.',
'Defeat Wu Zhaoshan.'
],
doneText: 'The umbrellas folded, one by one, until the sky was only sky.' },
q_cure: { name: 'The Alchemist\u2019s Favor', stages: [
'Alchemist Bai needs 2 Snake Gallbladders and 3 Green Herbs.',
'Bring the ingredients to Alchemist Bai in Ember Town.'
],
doneText: 'Bais fever cure saved three children. She shares her real recipes.' },
q_missing: { name: 'The Missing Woodcutter', stages: [
'Ask Hunter Ren in the Bamboo Sea about Uncle Ping.',
'Search the deep bamboo past the third bend.',
'Tell Elder Chen the news.'
],
doneText: 'Uncle Ping rests beneath the willows now. The village will remember.' },
q_trial_az: { name: 'Trials of Azure Cloud', stages: [
'Bring 2 Lotus Herbs from the Bamboo Sea to Master Yun.',
'Deliver the herbs.'
],
doneText: 'The gates of Azure Cloud Sect stand open to you.' }
};
/* ---------------- ORIGINS ---------------- */
const ORIGINS = {
farm: { name: 'Farmhand\u2019s Child', attrs: { con: 1, str: 1 }, silver: 30, items: ['w_staff_1', 'c_bun', 'c_bun'], techs: ['t_staff_1', 't_univ_1'],
desc: 'Raised on hoe-handles and early mornings.' },
merchant: { name: 'Merchant\u2019s Heir', attrs: { cha: 2, luk: 1 }, silver: 260, items: ['ac_jade', 'gift_fan', 'c_bun'], techs: ['t_univ_1'],
desc: 'You know what everything costs, including pride.' },
scholar: { name: 'Runaway Scholar', attrs: { wit: 3 }, silver: 90, items: ['gift_book', 'c_bun'], techs: ['t_univ_1'],
desc: 'You memorized the classics, then ran off to live in one.' },
hunter: { name: 'Hunter\u2019s Child', attrs: { agi: 2, str: 1 }, silver: 60, items: ['w_blade_1', 'mat_pelt', 'mat_pelt'], techs: ['t_blade_1', 't_univ_1'],
desc: 'You gutted your first boar at nine.' },
orphan: { name: 'Street Orphan', attrs: { agi: 2, luk: 2 }, silver: 45, items: ['w_hidden_1', 'c_bun'], techs: ['t_hidden_1', 't_univ_1'],
desc: 'The streets fed you table scraps and knife skills alike.' },
foundling: { name: 'Temple Foundling', attrs: { con: 2, wit: 1 }, silver: 20, items: ['w_staff_1', 'c_pill_hp'], techs: ['t_staff_1', 't_univ_1'],
desc: 'Left at the temple gate with a staff and a name tag. Both served you well.' }
};
const ORIGIN_IDS = Object.keys(ORIGINS);
/* ---------------- RECIPES ---------------- */
const SMITH_UPGRADES = {
w_sword_1: { to: 'w_sword_2', silver: 120, mats: { mat_ore_1: 2 } },
w_sword_2: { to: 'w_sword_3', silver: 450, mats: { mat_ore_2: 2 } },
w_sword_3: { to: 'w_sword_4', silver: 1400, mats: { mat_ore_3: 1, mat_ore_2: 2 } },
w_blade_1: { to: 'w_blade_2', silver: 120, mats: { mat_ore_1: 2 } },
w_blade_2: { to: 'w_blade_3', silver: 450, mats: { mat_ore_2: 2 } },
w_blade_3: { to: 'w_blade_4', silver: 1400, mats: { mat_ore_3: 1, mat_ore_2: 2 } },
w_spear_1: { to: 'w_spear_2', silver: 120, mats: { mat_ore_1: 2 } },
w_spear_2: { to: 'w_spear_3', silver: 450, mats: { mat_ore_2: 2 } },
w_spear_3: { to: 'w_spear_4', silver: 1400, mats: { mat_ore_3: 1, mat_ore_2: 2 } },
w_staff_1: { to: 'w_staff_2', silver: 110, mats: { mat_ore_1: 2 } },
w_staff_2: { to: 'w_staff_3', silver: 430, mats: { mat_ore_2: 2 } },
w_staff_3: { to: 'w_staff_4', silver: 1350, mats: { mat_ore_3: 1, mat_ore_2: 2 } },
w_fist_1: { to: 'w_fist_2', silver: 110, mats: { mat_ore_1: 2 } },
w_fist_2: { to: 'w_fist_3', silver: 430, mats: { mat_ore_2: 2 } },
w_fist_3: { to: 'w_fist_4', silver: 1350, mats: { mat_ore_3: 1, mat_ore_2: 2 } },
w_hidden_1: { to: 'w_hidden_2', silver: 110, mats: { mat_ore_1: 2 } },
w_hidden_2: { to: 'w_hidden_3', silver: 430, mats: { mat_ore_2: 2 } },
w_hidden_3: { to: 'w_hidden_4', silver: 1350, mats: { mat_ore_3: 1, mat_ore_2: 2 } },
ar_body_1: { to: 'ar_body_2', silver: 90, mats: { mat_ore_1: 2 } },
ar_body_2: { to: 'ar_body_3', silver: 300, mats: { mat_ore_2: 2 } },
ar_body_3: { to: 'ar_body_4', silver: 900, mats: { mat_ore_3: 1, mat_ore_2: 2 } },
ar_head_1: { to: 'ar_head_2', silver: 70, mats: { mat_ore_1: 1 } },
ar_head_2: { to: 'ar_head_3', silver: 240, mats: { mat_ore_2: 1 } },
ar_head_3: { to: 'ar_head_4', silver: 750, mats: { mat_ore_3: 1 } },
ar_foot_1: { to: 'ar_foot_2', silver: 60, mats: { mat_pelt: 2 } },
ar_foot_2: { to: 'ar_foot_3', silver: 200, mats: { mat_ore_1: 1, mat_pelt: 1 } },
ar_foot_3: { to: 'ar_foot_4', silver: 650, mats: { mat_ore_2: 1, mat_pelt: 2 } }
};
const ALCHEMY_RECIPES = [
{ id: 'r_hp', name: 'Healing Pill ×2', out: 'c_pill_hp', qty: 2, mats: { c_herb_red: 2 }, silver: 15, needFlag: null },
{ id: 'r_mp', name: 'Clear-Mind Pill ×2', out: 'c_pill_mp', qty: 2, mats: { c_lotus: 1, c_herb_green: 1 }, silver: 15, needFlag: null },
{ id: 'r_anti', name: 'Antidote Powder ×2', out: 'c_antidote', qty: 2, mats: { c_herb_green: 2 }, silver: 10, needFlag: null },
{ id: 'r_pow', name: 'Battle Elixir', out: 'c_elixir_power', qty: 1, mats: { mat_gall: 1, c_herb_red: 2 }, silver: 40, needFlag: 'knowsAlchemy' },
{ id: 'r_swi', name: 'Swift Elixir', out: 'c_elixir_swift', qty: 1, mats: { c_lotus: 1, mat_fang: 1 }, silver: 40, needFlag: 'knowsAlchemy' },
{ id: 'r_ven', name: 'Coat of Venom', out: 'c_venom', qty: 1, mats: { mat_gall: 2 }, silver: 30, needFlag: 'knowsAlchemy' },
{ id: 'r_ton', name: 'Vital Tonic', out: 'c_tonic', qty: 1, mats: { c_lotus: 2, mat_gall: 1 }, silver: 80, needFlag: 'knowsAlchemy' }
];
/* ---------------- TOURNAMENT ---------------- */
ENEMIES['e_champ_camellia'] = Object.assign({}, ENEMIES.e_bandit_vet, {
id: 'e_champ_camellia', name: 'Madame Camellia', tier: 3, hp: 150, atk: 22, def: 10, spd: 12, silver: [80, 130], exp: 130,
moves: [
{ n: 'Silk Whip Combo', mp: 12, p: 96, r: 1, sh: 'foe', hits: 2 },
{ n: 'Camellia Stance', mp: 12, p: 0, r: 0, sh: 'self', buff: { stat: 'spd', pct: 0.3, dur: 2 } },
{ n: 'Petal Storm Kick', mp: 16, p: 150, r: 1, sh: 'cross' }
]
});
const TOURNEY_LADDER = [
{ name: '"Brickwall" Luo', base: 'e_thug' },
{ name: 'Iron Sash Wei', base: 'e_bandit_vet' },
{ name: 'Madame Camellia', base: 'e_champ_camellia' },
{ name: 'Blade Saint Yun-Fei', base: 'e_duelist' }
];
/* ---------------- BOUNTY POOLS ---------------- */
const BOUNTY_POOLS = {
1: ['e_wolf', 'e_boar', 'e_snake', 'e_bandit', 'e_thug'],
2: ['e_tiger', 'e_bandit_vet', 'e_bandit_chief', 'e_guard_deserter_placeholder'],
3: ['e_assassin', 'e_poisoner', 'e_bear']
};
ENEMIES['e_deserter'] = Object.assign({}, ENEMIES.e_bandit_vet, {
id: 'e_deserter', name: 'Runaway Soldier', tier: 2, def: 8,
moves: [{ n: 'Saber Drill', mp: 8, p: 138, r: 1, sh: 'foe' }, { n: 'Braced Guard', mp: 10, p: 0, r: 0, sh: 'self', shield: 35 }]
});
BOUNTY_POOLS[2] = ['e_tiger', 'e_bandit_vet', 'e_bandit_chief', 'e_deserter'];
/* ---------------- TEMPLE TRIAL QUEST ---------------- */
QUESTS['q_trial_fl'] = {
name: 'Sweeping the Courtyard',
stages: [
'Prove patience: meditate once in the temple hall, then spar with Monk Jing.',
'Report to Abbot Lian.'
],
doneText: 'The Pure Lotus Temple opens its gates; the broom, however, remains yours.'
};
/* ---------------- ACHIEVEMENTS ---------------- */
const ACHIEVEMENTS = {
first_blood: { name: 'First Blood', desc: 'Win your first battle.' },
ridge_guardian:{ name: 'Ridge Guardian', desc: 'Complete "Wolf Trouble".' },
champion: { name: 'Ring Champion', desc: 'Win the Qingyun tournament.' },
hong_heir: { name: 'Hong\u2019s Heir', desc: 'Learn the Ten Thousand Rivers Palms.' },
sun_dawn: { name: 'Blazing Dawn', desc: 'Cultivate the lost Blazing Sun Manual.' },
keeper_truth: { name: 'Keeper of Truth', desc: 'Claim the Heaven\u2019s Ledger.' },
fat_purse: { name: 'Heavy Purse', desc: 'Hold 2,000 silver at once.' },
journeyman: { name: 'Journeyman', desc: 'Reach level 10.' },
master_qi: { name: 'Master of Qi', desc: 'Reach level 20.' },
band_three: { name: 'Band of Three', desc: 'Travel with three companions.' },
two_rivers: { name: 'Two Rivers, One Heart', desc: 'Find romance on the road.' },
after_dark: { name: 'After-Dark Customer',desc: 'Set foot in the Ghost Market.' },
water_patience:{ name: 'Patience of Water', desc: 'Catch 25 fish in a lifetime.' },
umbrellas_fall:{ name: 'Umbrellas Fold', desc: 'Finish the main story.' }
};
/* ---------------- BESTIARY LORE ---------------- */
const BEAST_LORE = {
e_wolf: 'Grey pack-hunters that grow bold whenever granaries do.',
e_boar: 'All shoulder and temper; farmers tell stories, fences tell more.',
e_snake: 'Green as spring bamboo, twice as patient.',
e_tiger: 'The Cloudmist stripe is prized by poets and avoided by everyone else.',
e_bear: 'It owns the berry slopes. You are renting.',
e_bandit: 'A knife, a grudge, and poor arithmetic.',
e_bandit_vet: 'Survived enough ambushes to run one properly.',
e_bandit_chief: 'Promoted by attrition \u2014 the men above him kept dying.',
e_thug: 'Collects debts for people who dislike paperwork.',
e_guard: 'Garrison drilled, halberd regulation, patience limited.',
e_assassin: 'An umbrella\u2019s shadow, sharpened.',
e_cultist: 'Sworn to silver paper oaths they cannot show their families.',
e_poisoner: 'Expelled from the Valley for excessive enthusiasm.',
e_duelist: 'Walks from town to town collecting styles and scars.',
e_sentinel: 'Carved to guard, carved to last, carved to kill.',
e_boss_qiang: 'Lost an eye to a magistrate\u2019s chain; repaid the ledger in lead.',
e_boss_dukang: 'Silk manners, silver umbrella, no refunds.',
e_boss_heihu: 'The Patriarch\u2019s enforcer. The umbrella never opens twice.',
e_boss_wu: 'Built the Iron Umbrella Society the way rivers build canyons.',
e_deserter: 'Kept the training, misplaced the flag.',
e_champ_camellia: 'Retired opera dancer; the kicks stayed.'
};
/* tier-V ultimates & support arts — masters' final teachings */
NPCS.n_hunter_ren.teach.push({ tech: 't_blade_5', aff: 75, cost: 2400 });
NPCS.n_master_yun.teach.push({ tech: 't_sword_5', aff: 80, cost: 2600 });
NPCS.n_abbot_lian.teach.push({ tech: 't_staff_5', aff: 80, cost: 2600 }, { tech: 't_univ_2', aff: 45, cost: 900 }, { tech: 't_univ_3', aff: 60, cost: 1200 });
NPCS.n_master_guo.teach.push({ tech: 't_fist_5', aff: 80, cost: 2600 });
NPCS.n_lady_miao.teach.push({ tech: 't_hidden_5', aff: 78, cost: 2500 });
NPCS.n_drill_capt.teach = [{ tech: 't_spear_5', aff: 70, cost: 2400 }];
/* richer shop stocks (late-game reach) */
NPCS.n_fence_wu.shop.stock.push('m_t_sword_5', 'm_t_blade_5', 'm_t_spear_5', 'm_t_staff_5', 'm_t_fist_5', 'm_t_hidden_5', 'm_univ_2', 'm_univ_3', 'ar_head_4', 'ar_body_4', 'ar_foot_4', 'ac_eye');
NPCS.n_smith_wang.shop.stock.push('ar_head_3');
NPCS.n_merchant_su.shop.stock.push('ar_foot_2');
/* ---------------- SIDE JOB QUESTS (job board) ---------------- */
Object.assign(QUESTS, {
q_herb_bai: { name: 'Bitter Roots, Kind Hearts', stages: ['Gather 5 Red Herb (herb spots in the wilds).', 'Deliver them at the job board.'],
doneText: 'Bai\u2019s shelves are full again; your pockets are, too.' },
q_ring_reeds: { name: 'The Ring in the Reeds', stages: ['Widow Chen\u2019s ring went down with a serpent\u2019s dinner. Cull 4 valley snakes.', 'Deliver the news at the job board.'],
doneText: 'The ring was in the third belly. The fourth was personal.' },
q_forge_coal: { name: 'Fuel for the Forge', stages: ['Prospect ore twice at Cloudmist Ridge.', 'Report to the job board.'],
doneText: 'Wang\u2019s forge roars for a week. He names an anvil after you.' },
q_night_watch: { name: 'Night Watch', stages: ['Cull 3 bandits on the roads.', 'Collect your wage at the job board.'],
doneText: 'The pass is quieter. Pei pretends not to be impressed.' },
q_alms_lotus: { name: 'Alms for the Lotus', stages: ['Donate 300 silver to the temple fund.', "Receive the abbot's blessing at the job board."],
doneText: 'Three hundred silver, one quiet mind. Fair trade.' },
q_lantern_dead: { name: 'Lanterns for the Dead', stages: ['Bring 2 Mulled Wine and 1 Lotus Herb for the river rite.', 'Deliver them at the job board.'],
doneText: 'Two lanterns downstream, one old song, no ghosts.' }
});
+313
View File
@@ -0,0 +1,313 @@
'use strict';
/* ============================================================
ENGINE — utilities, state, derived stats, time, log, sfx, saves
============================================================ */
const Util = {
_uid: 0,
ri(a, b) { return Math.floor(Math.random() * (b - a + 1)) + a; },
rf() { return Math.random(); },
chance(p) { return Math.random() < p; },
pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; },
shuffle(arr) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; },
clamp(v, a, b) { return Math.max(a, Math.min(b, v)); },
uid() { return 'u' + (++Util._uid); },
esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); },
money(n) { return n + ' silver'; },
dist(ax, ay, bx, by) { return Math.max(Math.abs(ax - bx), Math.abs(ay - by)); }, // Chebyshev
manhattan(ax, ay, bx, by) { return Math.abs(ax - bx) + Math.abs(ay - by); },
cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; },
plural(n, s) { return n + ' ' + s + (n === 1 ? '' : 's'); }
};
const ATTRS = [
['str', 'Strength 臂力', 'Melee damage'],
['con', 'Constitution 体质', 'Health & defense'],
['agi', 'Agility 身法', 'Speed & evasion'],
['wit', 'Wit 悟性', 'Learning & inner energy'],
['luk', 'Fortune 福缘', 'Critical hits & lucky finds'],
['cha', 'Charm 魅力', 'Persuasion & prices']
];
/* ---------------- global mutable state ---------------- */
const G = {
player: null,
loc: null,
discovered: {},
flags: {}, // story flags & counters
aff: {}, // npcId -> affinity 0..100
rep: { fame: 0, infamy: 0, azure: 0, temple: 0, serpent: 0, fist: 0, garrison: 0, underground: 0 },
quests: {}, // qid -> {stage:int, done:bool}
party: [], // companion npcIds (max 2 fight alongside)
allies: [], // recruited npcIds (roster beyond party)
time: { day: 1, hour: 8 },
log: [],
daily: null,
bounty: { list: [], taken: null },
tourney: { streak: 0, lastDay: -99 },
settings: { sfx: true, aiSpeed: 380 },
battleMeta: null,
partner: null,
inBattle: false
};
function defaultDaily() {
return {
dummyPts: 0, sparred: {}, giftsGiven: {}, gathered: {}, mined: false,
pickpocket: false, meditated: 0, bountyDone: false, gambled: 0,
fished: 0
};
}
/* ---------------- player factory ---------------- */
function makePlayer(opts) {
const origin = DATA.ORIGINS[opts.origin] || DATA.ORIGINS.farm;
const attrs = { str: 4, con: 4, agi: 4, wit: 4, luk: 4, cha: 4 };
for (const k of Object.keys(attrs)) attrs[k] += (origin.attrs[k] || 0);
for (const k of Object.keys(opts.attrs || {})) {
if (!(k in attrs)) continue;
attrs[k] += opts.attrs[k];
attrs[k] = Math.min(attrs[k], 9); // creation cap
}
const p = {
name: opts.name || 'Nameless',
gender: opts.gender || 'm',
origin: opts.origin || 'farm',
attrs, lvl: 1, exp: 0, silver: origin.silver,
hp: 1, mp: 1, // set by recalc below
equip: { weapon: null, head: null, body: null, feet: null, acc1: null, acc2: null },
inv: [],
techs: {}, // techId -> proficiency 0..100
internal: origin.internal || 'i_water',
internalLv: 1, internalExp: 0,
knownInternals: ['i_water'],
light: 'l_basic',
knownLights: ['l_basic'],
permBonuses: { hp: 0, mp: 0 } // permanent elixir gains
};
for (const id of (origin.items || [])) Game_addItemRaw(p, id);
for (const t of (origin.techs || [])) p.techs[t] = 10;
if (!Object.keys(p.techs).length) p.techs['t_univ_1'] = 10;
Stats.recalc(p);
p.hp = p.d.maxHp; p.mp = p.d.maxMp;
return p;
}
// raw inventory add used before Game exists
function Game_addItemRaw(p, id, q) {
q = q || 1;
const stackable = !['weapon', 'body', 'head', 'feet'].includes((DATA.ITEMS[id] || {}).type);
const ex = p.inv.find(s => s.id === id && stackable);
if (ex) ex.q += q; else p.inv.push({ id, q });
}
/* ---------------- derived stats ---------------- */
const Stats = {
expNext(lvl) { return Math.round(60 * Math.pow(lvl, 1.35)); },
recalc(p) {
// effective attributes = base + accessory bonuses
const eff = { str: p.attrs.str, con: p.attrs.con, agi: p.attrs.agi, wit: p.attrs.wit, luk: p.attrs.luk, cha: p.attrs.cha };
let atk = 5 + eff.str * 3 + (p.lvl - 1) * 2;
let def = eff.con * 2;
let hpFlat = 80 + eff.con * 12 + (p.lvl - 1) * 10;
let mpFlat = 30 + eff.wit * 6 + (p.lvl - 1) * 5;
let spd = 8 + eff.agi * 2;
let crit = 3 + eff.luk * 1;
let dodge = Math.round(eff.agi * 1.5);
// equipment
let rng = 1, wt = 'fist';
for (const slot of Object.keys(p.equip)) {
const id = p.equip[slot]; if (!id) continue;
const it = DATA.ITEMS[id]; if (!it) continue;
atk += it.atk || 0; def += it.def || 0; spd += it.spd || 0;
crit += it.crit || 0; dodge += it.dodge || 0;
hpFlat += it.hp || 0; mpFlat += it.mp || 0;
if (slot === 'weapon') { rng = it.rng || 1; wt = it.wt || 'sword'; }
if (it.bonus) {
for (const bk of Object.keys(it.bonus)) {
eff[bk] = (eff[bk] || 0) + it.bonus[bk];
if (bk === 'str') atk += 3 * it.bonus[bk];
if (bk === 'con') { def += 2 * it.bonus[bk]; hpFlat += 12 * it.bonus[bk]; }
if (bk === 'agi') { spd += 2 * it.bonus[bk]; dodge += Math.round(1.5 * it.bonus[bk]); }
if (bk === 'wit') mpFlat += 6 * it.bonus[bk];
if (bk === 'luk') crit += it.bonus[bk];
}
}
}
// internal art
const ia = DATA.INTERNALS[p.internal];
if (ia) {
const L = p.internalLv;
hpFlat += (ia.hp0 || 0) + (ia.hpL || 0) * L;
mpFlat += (ia.mp0 || 0) + (ia.mpL || 0) * L;
atk += (ia.atk0 || 0) + (ia.atkL || 0) * L;
def += (ia.def0 || 0) + (ia.defL || 0) * L;
spd += (ia.spd0 || 0) + (ia.spdL || 0) * L;
crit += (ia.crit0 || 0) + (ia.critL || 0) * L;
dodge += (ia.dodge0 || 0) + (ia.dodgeL || 0) * L;
}
// lightness skill
const li = DATA.LIGHTNESS[p.light];
let mvBonus = 0;
if (li) { mvBonus = li.mv || 0; dodge += li.dodge || 0; spd += li.spd || 0; }
hpFlat += p.permBonuses.hp; mpFlat += p.permBonuses.mp;
const d = {
atk, def, spd, crit, dodge,
maxHp: hpFlat, maxMp: mpFlat,
rng, wt,
mv: Util.clamp(3 + Math.floor(spd / 15) + mvBonus, 3, 10),
regenMpPct: 5 + ((ia && ia.mpRegen) || 0)
};
p.d = d;
if (typeof Game !== 'undefined' && Game.uiDirty) {} // no-op hook
return d;
},
// build a plain summary for UI
sheet(p) {
Stats.recalc(p);
return p.d;
}
};
/* ---------------- time ---------------- */
const TimeSys = {
advance(hours) {
hours = Math.max(0, Math.round(hours));
G.time.hour += hours;
let rolled = false;
while (G.time.hour >= 24) { G.time.hour -= 24; G.time.day++; rolled = true; }
if (rolled && typeof Game !== 'undefined' && Game.dailyReset) Game.dailyReset();
if (typeof UI !== 'undefined' && UI.refreshTop) UI.refreshTop();
},
isNight() { return G.time.hour >= 20 || G.time.hour < 5; },
str() {
const h = String(G.time.hour).padStart(2, '0');
const phase = G.time.hour < 5 ? 'Small Hours' : G.time.hour < 8 ? 'Dawn' : G.time.hour < 12 ? 'Morning'
: G.time.hour < 14 ? 'Noon' : G.time.hour < 18 ? 'Afternoon' : G.time.hour < 20 ? 'Dusk' : 'Night';
return `Day ${G.time.day} · ${phase} (${h}:00)`;
}
};
/* ---------------- log ---------------- */
const Log = {
add(msg, cls) {
G.log.push({ m: msg, c: cls || '', t: Date.now() });
if (G.log.length > 250) G.log.shift();
const el = (typeof document !== 'undefined') && document.getElementById('log');
if (el) {
const p = document.createElement('p');
if (cls) p.className = cls;
p.innerHTML = msg;
el.appendChild(p);
while (el.children.length > 120) el.removeChild(el.firstChild);
el.scrollTop = el.scrollHeight;
} else if (typeof console !== 'undefined') {
console.log('[log]', msg.replace(/<[^>]*>/g, ''));
}
},
clearDom() {
const el = (typeof document !== 'undefined') && document.getElementById('log');
if (el) el.innerHTML = '';
for (const l of G.log.slice(-30)) {
if (!el) break;
const p = document.createElement('p');
p.className = l.c; p.innerHTML = l.m;
el.appendChild(p);
}
}
};
/* ---------------- sound (tiny synth) ---------------- */
const Sfx = {
ctx: null,
ensure() {
if (!G.settings.sfx) return null;
try {
if (!Sfx.ctx) Sfx.ctx = new (window.AudioContext || window.webkitAudioContext)();
return Sfx.ctx;
} catch (e) { return null; }
},
tone(freq, dur, type, vol, when) {
const ctx = Sfx.ensure(); if (!ctx) return;
const t0 = ctx.currentTime + (when || 0);
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = type || 'square'; o.frequency.value = freq;
g.gain.setValueAtTime(vol || 0.05, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o.connect(g); g.connect(ctx.destination);
o.start(t0); o.stop(t0 + dur + 0.02);
},
play(name) {
switch (name) {
case 'click': Sfx.tone(520, .06, 'triangle', .04); break;
case 'hit': Sfx.tone(160, .12, 'square', .06); Sfx.tone(90, .16, 'sawtooth', .05, .02); break;
case 'crit': Sfx.tone(220, .1, 'square', .07); Sfx.tone(330, .12, 'square', .06, .05); Sfx.tone(440, .14, 'triangle', .05, .1); break;
case 'heal': Sfx.tone(440, .12, 'sine', .05); Sfx.tone(660, .16, 'sine', .05, .08); break;
case 'coin': Sfx.tone(880, .07, 'triangle', .05); Sfx.tone(1320, .09, 'triangle', .04, .05); break;
case 'levelup': [523, 659, 784, 1047].forEach((f, i) => Sfx.tone(f, .14, 'triangle', .06, i * .09)); break;
case 'victory': [392, 523, 659, 784].forEach((f, i) => Sfx.tone(f, .18, 'triangle', .06, i * .11)); break;
case 'defeat': [330, 262, 196].forEach((f, i) => Sfx.tone(f, .25, 'sawtooth', .05, i * .18)); break;
case 'open': Sfx.tone(300, .08, 'sine', .04); break;
}
}
};
/* ---------------- saves ---------------- */
const SaveSys = {
KEY: 'jianghu_save_',
snapshot() {
return JSON.stringify({
v: 1, when: Date.now(),
player: G.player, loc: G.loc, discovered: G.discovered, flags: G.flags,
aff: G.aff, rep: G.rep, quests: G.quests, party: G.party, allies: G.allies,
time: G.time, daily: G.daily, bounty: G.bounty, tourney: G.tourney,
settings: G.settings, partner: G.partner
});
},
restore(json) {
const o = JSON.parse(json);
G.player = o.player; G.loc = o.loc; G.discovered = o.discovered || {};
G.flags = o.flags || {}; G.aff = o.aff || {}; G.rep = o.rep || G.rep;
G.quests = o.quests || {}; G.party = o.party || []; G.allies = o.allies || [];
G.time = o.time || { day: 1, hour: 8 }; G.daily = o.daily || defaultDaily();
G.bounty = o.bounty || { list: [], taken: null };
G.tourney = o.tourney || { streak: 0, lastDay: -99 };
G.settings = Object.assign({ sfx: true, aiSpeed: 380 }, o.settings);
G.partner = o.partner || null;
G.log = []; G.inBattle = false; G.battleMeta = null;
Stats.recalc(G.player);
},
save(slot) {
try { localStorage.setItem(SaveSys.KEY + slot, SaveSys.snapshot()); return true; }
catch (e) { Log.add('Save failed: ' + e.message, 'bad'); return false; }
},
load(slot) {
const s = localStorage.getItem(SaveSys.KEY + slot);
if (!s) return false;
SaveSys.restore(s); return true;
},
peek(slot) {
const s = localStorage.getItem(SaveSys.KEY + slot);
if (!s) return null;
try { const o = JSON.parse(s); return { day: o.time.day, name: o.player.name, lvl: o.player.lvl, when: o.when }; }
catch (e) { return null; }
},
del(slot) { localStorage.removeItem(SaveSys.KEY + slot); },
list() {
const out = [];
for (const slot of ['auto', '1', '2', '3']) {
const info = SaveSys.peek(slot);
out.push({ slot, info });
}
return out;
},
autosave() { SaveSys.save('auto'); },
exportStr() { return btoa(unescape(encodeURIComponent(SaveSys.snapshot()))); },
importStr(str) {
try { SaveSys.restore(decodeURIComponent(escape(atob(str.trim())))); return true; }
catch (e) { return false; }
}
};
+1031
View File
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
'use strict';
/* ============================================================
MAIN boot, data validation
============================================================ */
function Data_validate() {
const problems = [];
const chkItem = (id, where) => { if (!ITEMS[id]) problems.push(`missing item ${id} (${where})`); };
// shops
for (const nid of Object.keys(NPCS)) {
const n = NPCS[nid];
if (n.shop) n.shop.stock.forEach(i => chkItem(i, 'shop ' + nid));
(n.teach || []).forEach(t => {
if (t.tech && !TECHNIQUES[t.tech]) problems.push(`npc ${nid} teaches missing tech ${t.tech}`);
if (t.internal && !INTERNALS[t.internal]) problems.push(`npc ${nid} teaches missing internal ${t.internal}`);
});
if (n.dlg && !DIALOGUES[n.dlg]) problems.push(`npc ${nid} dialogue missing: ${n.dlg}`);
}
// locations
for (const lid of Object.keys(LOCS)) {
LOCS[lid].npcs.forEach(nid => { if (!NPCS[nid]) problems.push(`loc ${lid} npc missing ${nid}`); });
}
TRAVEL.forEach(([a, b]) => {
if (!LOCS[a]) problems.push('travel missing loc ' + a);
if (!LOCS[b]) problems.push('travel missing loc ' + b);
});
// quests referenced in dialogues (rough scan)
const refs = JSON.stringify(DIALOGUES).match(/q_[a-z0-9_]+/g) || [];
[...new Set(refs)].forEach(q => { if (!QUESTS[q]) problems.push('dialogue references unknown quest ' + q); });
// techniques referenced as manuals
Object.values(ITEMS).filter(i => i.type === 'manual').forEach(m => {
if (!TECHNIQUES[m.teach] && !INTERNALS[m.teach] && !LIGHTNESS[m.teach]) problems.push(`manual ${m.id} teaches unknown ${m.teach}`);
});
// origins items/techs
Object.entries(ORIGINS).forEach(([oid, o]) => {
(o.items || []).forEach(i => chkItem(i, 'origin ' + oid));
(o.techs || []).forEach(t => { if (!TECHNIQUES[t]) problems.push(`origin ${oid} tech ${t} missing`); });
});
// upgrades / recipes
Object.entries(SMITH_UPGRADES).forEach(([k, up]) => { chkItem(k, 'smith'); chkItem(up.to, 'smith->'); Object.keys(up.mats).forEach(m => chkItem(m, 'smith mat')); });
ALCHEMY_RECIPES.forEach(r => { chkItem(r.out, 'alch'); Object.keys(r.mats).forEach(m => chkItem(m, 'alch mat')); });
// dialogues nodes integrity
for (const did of Object.keys(DIALOGUES)) {
const D = DIALOGUES[did];
for (const nid of Object.keys(D.nodes)) {
const opts = typeof D.nodes[nid].opts === 'function' ? [] : (D.nodes[nid].opts || []);
opts.forEach(o => { if (o.next && !D.nodes[o.next]) problems.push(`${did}.${nid} -> missing node ${o.next}`); });
}
}
if (problems.length) {
console.warn('[Data] issues found:');
problems.forEach(p => console.warn(' -', p));
} else {
console.log('[Data] validation passed ✔');
}
return problems;
}
window.addEventListener('load', () => {
Data_validate();
UI.init();
UI.renderTitleMenu();
Log.add('The jianghu stirs\u2026', 'sys');
});
+1183
View File
File diff suppressed because it is too large Load Diff
+1106
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
/* zero-dependency static server with aggressive no-caching */
const http = require('http');
const fs = require('fs');
const path = require('path');
const ROOT = __dirname;
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json' };
http.createServer((req, res) => {
let p = decodeURIComponent((req.url || '/').split('?')[0]);
if (p === '/') p = '/index.html';
const file = path.normalize(path.join(ROOT, p));
if (!file.startsWith(ROOT)) { res.writeHead(403); return res.end(); }
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404, { 'Cache-Control': 'no-store' }); return res.end('not found'); }
res.writeHead(200, {
'Content-Type': MIME[path.extname(file)] || 'application/octet-stream',
'Cache-Control': 'no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
});
res.end(data);
});
}).listen(8917, '127.0.0.1', () => console.log('wulin serving on http://127.0.0.1:8917 (no-store)'));
+412
View File
@@ -0,0 +1,412 @@
/* Headless smoke test: loads all game scripts in a VM context with
minimal browser stubs, validates data, creates a hero, and runs
AI-vs-AI battles to completion. */
import fs from 'fs';
import path from 'path';
import vm from 'vm';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const FILES = [
'js/data.items.js',
'js/data.world.js',
'js/data.dialogues.js',
'js/engine.js',
'js/combat.js',
'js/game.js',
'js/world2d.js',
];
const sandbox = {
console,
setTimeout, clearTimeout,
performance: { now: () => Date.now() },
localStorage: (() => { const m = {}; return { getItem: k => (k in m ? m[k] : null), setItem: (k, v) => { m[k] = String(v); }, removeItem: k => { delete m[k]; } }; })(),
window: { addEventListener() {} },
requestAnimationFrame: () => {},
btoa: s => Buffer.from(s, 'binary').toString('base64'),
atob: s => Buffer.from(s, 'base64').toString('binary')
};
vm.createContext(sandbox);
for (const f of FILES) {
const code = fs.readFileSync(path.join(ROOT, f), 'utf8');
vm.runInContext(code, sandbox, { filename: f });
}
// stub browser-only layer
vm.runInContext(`
var UI = { showScreen(){}, setTab(){}, refresh(){}, refreshTop(){}, toastSafe(){}, modal(){}, closeModal(){},
openLevelUp(){}, openBattle(b){ if(!b.round) b.startRound(); }, closeBattle(){}, openSmith(){}, openAlchemy(){},
openTrainPick(){}, openBoard(){}, openGamble(){}, renderTab(){}, showEnding(){}, openSystem(){}, renderTitleMenu(){} };
var Toast = { show(){} };
var ACTION_DEFS = { herb:[], mine:[], meditate:[], dummy:[], secttasks:[], board:[], gamble:[], pickpocket:[], camp:[], fish:[] };
UI.runAction = function (act) { if (!ACTION_DEFS[act.id]) throw new Error('unmapped action ' + act.id); };
`, sandbox);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
let failed = 0;
const t = (name, fn) => {
try { fn(); console.log('✔', name); }
catch (e) { failed++; console.error('✘', name, '\n ', String(e.stack).split('\n').slice(0, 4).join('\n ')); }
};
function validateData() {
const issues = vm.runInContext(`(function(){
var issues = [];
for (var nid in NPCS) { var n = NPCS[nid];
if (n.dlg && !DIALOGUES[n.dlg]) issues.push('npc ' + nid + ' dlg ' + n.dlg);
if (n.shop) n.shop.stock.forEach(function(i){ if (!ITEMS[i]) issues.push('shop ' + nid + ' item ' + i); });
(n.teach || []).forEach(function(x){ if (x.tech && !TECHNIQUES[x.tech]) issues.push('teach ' + x.tech); if (x.internal && !INTERNALS[x.internal]) issues.push('teach int ' + x.internal); });
}
for (var lid in LOCS) LOCS[lid].npcs.forEach(function(nid){ if (!NPCS[nid]) issues.push('loc ' + lid + ' npc ' + nid); });
TRAVEL.forEach(function(e){ if (!LOCS[e[0]] || !LOCS[e[1]]) issues.push('travel loc'); });
Object.keys(ORIGINS).forEach(function(oid){ (ORIGINS[oid].items || []).forEach(function(i){ if (!ITEMS[i]) issues.push('origin ' + oid + ' item ' + i); }); });
Object.keys(ITEMS).forEach(function(iid){ var m = ITEMS[iid];
if (m.type === 'manual' && !TECHNIQUES[m.teach] && !INTERNALS[m.teach] && !LIGHTNESS[m.teach]) issues.push('manual ' + iid);
});
Object.keys(SMITH_UPGRADES).forEach(function(k){ var up = SMITH_UPGRADES[k];
if (!ITEMS[k] || !ITEMS[up.to]) issues.push('smith ' + k);
Object.keys(up.mats).forEach(function(m){ if (!ITEMS[m]) issues.push('smith mat ' + m); });
});
ALCHEMY_RECIPES.forEach(function(r){ if (!ITEMS[r.out]) issues.push('alch out ' + r.out);
Object.keys(r.mats).forEach(function(m){ if (!ITEMS[m]) issues.push('alch mat ' + m); });
});
return issues;
})()`, sandbox);
if (issues.length) throw new Error(issues.join('; '));
}
async function main() {
t('data cross-references resolve', validateData);
t('newGame creates a hero with derived stats', () => {
vm.runInContext(`Game.newGame({ name:'Smoke Tester', gender:'m', origin:'hunter', attrs:{str:2,agi:2} });`, sandbox);
const info = vm.runInContext(`({name:G.player.name, hp:G.player.d.maxHp, atk:G.player.d.atk, blade:!!G.player.techs['t_blade_1']})`, sandbox);
if (info.name !== 'Smoke Tester') throw new Error('no player');
if (!info.hp || !info.atk || !info.blade) throw new Error('derived/tech missing');
});
t('equipment bonuses apply', () => {
vm.runInContext(`G.player.equip.acc1='ac_jade'; Stats.recalc(G.player); G.player.equip.acc1=null; Stats.recalc(G.player);`, sandbox);
});
t('headless battle runs to completion', async () => {
vm.runInContext(`
G.party = ['n_tiehu'];
Game.startBattle({ ids:['e_wolf','e_wolf'], scale:0.8, instant:true, meta:{kind:'wild'} });
var _b = G.battle;
if (!_b) throw new Error('battle missing');
_b.allies.find(u=>u.isPlayer).ai = 'brute';
`, sandbox);
const deadline = Date.now() + 10000;
while (!vm.runInContext('!G.battle || G.battle.over', sandbox)) {
if (Date.now() > deadline) throw new Error('battle stalled');
await sleep(40);
}
const res = vm.runInContext(`({win:_b.win, lvl:G.player.lvl, silver:G.player.silver, inBattle:G.inBattle})`, sandbox);
if (!res.win) throw new Error('expected AI win vs wolves');
if (res.inBattle) throw new Error('inBattle stuck true');
});
t('player-driven battle accepts input & casts', async () => {
vm.runInContext(`
G.player.hp = G.player.d.maxHp; G.player.mp = G.player.d.maxMp;
Game.startBattle({ ids:['e_bandit'], scale:0.7, instant:true, meta:{kind:'wild'} });
var _b2 = G.battle;
if (!_b2) throw new Error('no battle');
_b2.queue = [_b2.allies.find(u=>u.isPlayer)]; _b2.turnIdx = -1;
_b2.startRound();
var pu = _b2.current;
if (!pu || !pu.isPlayer) throw new Error('player not first: ' + (pu&&pu.name));
var foe = _b2.enemies[0];
// teleport foe adjacent
foe.x = pu.x + 1; foe.y = pu.y;
_b2.beginCast(pu, 't_univ_1', 2);
if (!_b2.selectedMove) throw new Error('cast not selected');
_b2.clickTile(foe.x, foe.y);
if (_b2.selectedMove) throw new Error('cast not cleared');
if (!_b2.current.acted) throw new Error('acted flag not set');
`, sandbox);
await sleep(120);
});
t('travel, time and daily reset work', () => {
vm.runInContext(`
G.flags.unlockedCity = true; G.flags.hongVisible = false;
Game.travelTo('l_city');
if (G.loc !== 'l_city') throw new Error('travel failed');
var d0 = G.time.day;
G.time.hour = 23; TimeSys.advance(2);
if (G.time.day !== d0 + 1) throw new Error('day rollover failed');
`, sandbox);
});
t('save/load roundtrip preserves state', () => {
vm.runInContext(`
var s0 = G.player.silver;
SaveSys.save('1');
G.player.silver += 777;
SaveSys.load('1');
if (G.player.silver !== s0) throw new Error('mismatch ' + G.player.silver + ' vs ' + s0);
`, sandbox);
});
t('quests: start, stage, complete, rewards', () => {
vm.runInContext(`
var lvl0 = G.player.lvl;
Game.startQuest('q_main_1');
G.flags.wolfKills = 3;
Game.setStage('q_main_1', 2);
Game.completeQuest('q_main_1', { exp: 500, silver: 60, fame: 5, items:[['c_bun',2]] });
if (!G.quests['q_main_1'].done) throw new Error('not done');
if (G.player.lvl <= lvl0) throw new Error('no level gain');
if (!Game.hasItem('c_bun', 2)) throw new Error('reward items missing');
if (Game.questStageText('q_main_1') !== QUESTS['q_main_1'].doneText) throw new Error('stage text');
`, sandbox);
});
t('economy: buy/sell/smith/brew', () => {
vm.runInContext(`
G.player.silver = 5000;
var n0 = Game.countItem('c_herb_red');
Game.buyItem('n_merchant_su', 'c_herb_red');
if (Game.countItem('c_herb_red') !== n0 + 1) throw new Error('buy failed');
Game.sellItem('c_herb_red');
G.player.equip.weapon = 'w_sword_1';
G.player.inv.push({id:'mat_ore_1', q:5});
Game.smithUpgrade('w_sword_1');
if (G.player.equip.weapon !== 'w_sword_2' && !Game.hasItem('w_sword_2')) throw new Error('upgrade failed');
G.player.inv.push({id:'c_herb_red', q:5});
Game.brew('r_hp');
if (!Game.hasItem('c_pill_hp', 2)) throw new Error('brew failed');
`, sandbox);
});
t('fishing rewards & daily cap counter', () => {
vm.runInContext(`
G.loc = 'l_dock'; G.daily = defaultDaily();
var f0 = Game.countItem('c_fish');
Game.fishResult(true);
if (Game.countItem('c_fish') < f0 + 1) throw new Error('no fish caught');
Game.fishResult(false);
if ((G.daily.fished || 0) !== 2) throw new Error('cast counter broken');
if (!Game.canFish() || !LOCS['l_dock'].actions.some(a=>a.id==='fish')) throw new Error('cap/facility missing');
if (!ITEMS['c_fish'] || !ITEMS['j_boot']) throw new Error('fishing items missing');
`, sandbox);
});
t('hard mode scales enemy stats', async () => {
vm.runInContext(`
G.flags.hardMode = true;
G.party = [];
G.player.hp = G.player.d.maxHp; G.player.mp = G.player.d.maxMp;
Game.startBattle({ ids:['e_boar'], instant:true, meta:{kind:'wild'} });
var _hb = G.battle;
var expect = Math.round(ENEMIES['e_boar'].hp * 1.28);
if (_hb.enemies[0].hpMax !== expect) throw new Error('expected ' + expect + ', got ' + _hb.enemies[0].hpMax);
_hb.allies.find(u=>u.isPlayer).ai = 'brute';
`, sandbox);
const deadline = Date.now() + 8000;
while (!vm.runInContext('!G.battle || G.battle.over', sandbox)) {
if (Date.now() > deadline) throw new Error('hard-mode battle stalled');
await sleep(40);
}
vm.runInContext(`delete G.flags.hardMode;`, sandbox);
});
t('camp action exists in wilderness', () => {
const ok = vm.runInContext(`['l_mountain','l_bamboo','l_valley'].every(id => LOCS[id].actions.some(a => a.id === 'camp'))`, sandbox);
if (!ok) throw new Error('camp action missing somewhere');
});
t('achievements unlock idempotently', () => {
vm.runInContext(`
G.flags.ach = {};
Game.unlockAch('first_blood');
Game.unlockAch('first_blood');
if (Object.keys(G.flags.ach).length !== 1) throw new Error('not idempotent');
if (!ACHIEVEMENTS['water_patience'] || !ACHIEVEMENTS['umbrellas_fall']) throw new Error('meta missing');
`, sandbox);
});
t('bestiary records encountered foes', async () => {
vm.runInContext(`
delete G.flags.seen;
G.party = [];
G.player.hp = G.player.d.maxHp; G.player.mp = G.player.d.maxMp;
Game.startBattle({ ids:['e_wolf','e_boar'], instant:true, meta:{kind:'wild'} });
var _bb = G.battle;
_bb.allies.find(u=>u.isPlayer).ai = 'brute';
`, sandbox);
const deadline = Date.now() + 8000;
while (!vm.runInContext('!G.battle || G.battle.over', sandbox)) {
if (Date.now() > deadline) throw new Error('bestiary battle stalled');
await sleep(40);
}
const ok = vm.runInContext(`!!G.flags.seen && G.flags.seen['e_wolf'] && G.flags.seen['e_boar'] && !!BEAST_LORE['e_wolf']`, sandbox);
if (!ok) throw new Error('seen flags or lore missing');
vm.runInContext(`Game.startQuest('q_main_1');`, sandbox);
});
t('temple trial: meditate, spar, join', () => {
vm.runInContext(`
Game.startQuest('q_trial_fl');
Game.meditate();
if (!G.flags.trialFlMed) throw new Error('meditate not tracked');
Game.checkTrialFl();
if (G.quests['q_trial_fl'].stage !== 0) throw new Error('advanced without spar');
G.flags.trialFlSpar = true; Game.checkTrialFl();
if (G.quests['q_trial_fl'].stage !== 1) throw new Error('trial not advanced');
Game.completeQuest('q_trial_fl', {});
G.flags.templeMember = true; Game.checkQuestAuto();
if (QUESTS['q_trial_fl'].stages.length !== 2) throw new Error('bad stages');
`, sandbox);
});
t('companion chemistry passives apply', () => {
vm.runInContext(`
G.party = ['n_tiehu'];
var u1 = makePlayerUnit(G.player);
var baseHp = u1.hpMax / 1.08;
G.party = [];
var u0 = makePlayerUnit(G.player);
if (Math.round(u1.hpMax - u0.hpMax) < 1) throw new Error('no HP passive');
G.party = ['n_lin'];
var u2 = makePlayerUnit(G.player);
if (u2.crit <= u0.crit) throw new Error('no crit passive');
G.party = [];
`, sandbox);
});
t('world2d: deterministic maps with valid layout', () => {
vm.runInContext(`
var ids = Object.keys(LOCS);
ids.forEach(function (lid) {
var a = W2D.genGrid(lid), b = W2D.genGrid(lid);
if (!a || a.w < 20 || a.h < 20) throw new Error(lid + ' bad size');
// determinism
for (var y = 0; y < a.h; y++) for (var x = 0; x < a.w; x++) if (a.g[y][x] !== b.g[y][x]) throw new Error(lid + ' nondeterministic');
// border fully blocked EXCEPT carved exit gaps
var gaps = new Set();
a.exits.forEach(function (ex) {
for (var k = -1; k <= 1; k++) {
if (ex.tag === 'N' || ex.tag === 'S') { gaps.add((ex.x + k) + ',' + ex.y); }
else { gaps.add(ex.x + ',' + (ex.y + k)); }
}
});
var okTile = function (x, y) { return BLOCKED.has(a.g[y][x]) || gaps.has(x + ',' + y); };
for (var x2 = 0; x2 < a.w; x2++) { if (!okTile(x2, 0)) throw new Error(lid + ' open north'); if (!okTile(x2, a.h - 1)) throw new Error(lid + ' open south'); }
for (var y2 = 0; y2 < a.h; y2++) { if (!okTile(0, y2)) throw new Error(lid + ' open west'); if (!okTile(a.w - 1, y2)) throw new Error(lid + ' open east'); }
// exit count matches travel edges; gap tiles walkable
if (a.exits.length !== TRAVEL.filter(function(e){return e.includes(lid);}).length) throw new Error(lid + ' exit count');
a.exits.forEach(function (ex) { if (BLOCKED.has(a.g[ex.y][ex.x])) throw new Error(lid + ' exit gap blocked at ' + ex.x + ',' + ex.y); });
// spawn plaza walkable
if (BLOCKED.has(a.g[a.cy][a.cx])) throw new Error(lid + ' spawn blocked');
// npc/roam spots inside bounds
a.npcSpots.concat(a.roamSpots).forEach(function (p) { if (p.x<0||p.y<0||p.x>=a.w||p.y>=a.h) throw new Error(lid + ' spot oob'); });
});
if (W2D.styleFor('l_dock') !== 'dock') throw new Error('styleFor');
`, sandbox);
});
t('world2d: action defs cover every location action', () => {
const ok = vm.runInContext(`
var missing = [];
Object.keys(LOCS).forEach(function (lid) {
(LOCS[lid].actions || []).forEach(function (act) {
try { UI.runAction(act); } catch (e) { missing.push(lid + ':' + act.id); }
});
});
missing.length === 0;
`, sandbox);
if (!ok) throw new Error('unmapped action ids');
});
t('armor slots (head/feet/body) affect derived stats', () => {
vm.runInContext(`
var d0 = Stats.recalc(G.player).def, s0 = Stats.recalc(G.player).spd;
G.player.equip.head = 'ar_head_2';
G.player.equip.feet = 'ar_foot_2';
G.player.equip.body = 'ar_body_2';
var d1 = Stats.recalc(G.player).def, s1 = Stats.recalc(G.player).spd;
G.player.equip.head = G.player.equip.feet = G.player.equip.body = null;
Stats.recalc(G.player);
if (!(d1 > d0)) throw new Error('no def from armor');
if (!(s1 > s0)) throw new Error('no spd from boots');
`, sandbox);
});
t('tier-V arts exist and are taught by masters', () => {
const ok = vm.runInContext(`
['t_sword_5','t_blade_5','t_spear_5','t_staff_5','t_fist_5','t_hidden_5','t_univ_2','t_univ_3'].every(function(t){ return TECHNIQUES[t] && TECHNIQUES[t].moves.length >= 2; }) &&
NPCS.n_master_yun.teach.some(function(x){return x.tech==='t_sword_5';}) &&
NPCS.n_drill_capt.teach.some(function(x){return x.tech==='t_spear_5';}) &&
ITEMS['m_univ_2'] && ITEMS['m_t_fist_5'] && ITEMS['ar_body_5'];
`, sandbox);
if (!ok) throw new Error('tier-V content incomplete');
});
t('side job lifecycle: accept, progress, deliver', () => {
vm.runInContext(`
Game.sideStart('q_night_watch');
if (Game.sideState('q_night_watch') !== 'active') throw new Error('not active');
Game.registerKill({ enemyId: 'e_bandit' });
Game.registerKill({ enemyId: 'e_bandit_vet' });
Game.registerKill({ enemyId: 'e_bandit' });
if (Game.sideState('q_night_watch') !== 'ready') throw new Error('not ready after 3 kills: ' + Game.sideState('q_night_watch'));
var sil0 = G.player.silver;
Game.sideDeliver('q_night_watch');
if (!G.quests['q_night_watch'].done) throw new Error('not done');
if (G.player.silver <= sil0) throw new Error('no wage paid');
Game.removeItem('c_herb_red', Game.countItem('c_herb_red'));
Game.addItem('c_herb_red', 5);
Game.sideStart('q_herb_bai');
if (Game.sideState('q_herb_bai') !== 'ready') throw new Error('herb not ready');
Game.sideDeliver('q_herb_bai');
if (Game.hasItem('c_herb_red', 1)) throw new Error('herbs not consumed');
`, sandbox);
});
t('spar restores vitals & grants affection', async () => {
vm.runInContext(`
G.loc='l_dock'; G.time.day=2; Game.dailyReset();
var hp0 = G.player.hp = G.player.d.maxHp;
Game.spar('n_tiehu');
if (!G.battle) throw new Error('spar battle missing');
G.battle.allies.find(u=>u.isPlayer).ai='brute';
`, sandbox);
const deadline = Date.now() + 10000;
while (!vm.runInContext('!G.battle || G.battle.over', sandbox)) {
if (Date.now() > deadline) throw new Error('spar stalled');
await sleep(40);
}
const st = vm.runInContext(`({hp:G.player.hp, aff:G.aff['n_tiehu']||0})`, sandbox);
if (st.hp < vm.runInContext('G.player.d.maxHp', sandbox) - 1) throw new Error('hp not restored: ' + st.hp);
});
t('main quest chain reaches the tomb stage', () => {
vm.runInContext(`
Game.startQuest('q_main_4'); // normally started by Du Kang's onWin
G.flags.mainChapter = 3;
Game.recruitCompanion('n_tiehu'); Game.recruitCompanion('n_lin');
if (G.quests['q_main_4'].stage === 0) throw new Error('allies stage not auto-advanced');
G.flags.azureMember = true; Game.checkQuestAuto();
if (G.quests['q_main_4'].stage !== 2) throw new Error('sect stage not advanced: ' + G.quests['q_main_4'].stage);
Game.completeQuest('q_main_4', {}); Game.startQuest('q_main_5');
if (FVsafe('mainChapter') < 3) throw new Error('chapter flag');
`, sandbox);
});
t('ending computes without crash', () => {
vm.runInContext(`
G.flags.mainChapter = 6; G.rep.fame = 300;
Game.showEnding();
`, sandbox);
});
console.log(failed ? `\n${failed} test(s) FAILED` : '\nAll smoke tests passed ✔');
process.exit(failed ? 1 : 0);
}
main();