From 8680f791028916d028b7c833c438b67598e45aa2 Mon Sep 17 00:00:00 2001 From: deepseek Date: Sun, 23 Aug 2026 07:01:08 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Jianghu=20Chroni?= =?UTF-8?q?cles:=20Road=20of=20the=20Wandering=20Blade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 2 + css/style.css | 263 ++++++++++ index.html | 48 ++ js/combat.js | 680 ++++++++++++++++++++++++ js/data.dialogues.js | 451 ++++++++++++++++ js/data.items.js | 330 ++++++++++++ js/data.world.js | 339 ++++++++++++ js/engine.js | 313 +++++++++++ js/game.js | 1031 ++++++++++++++++++++++++++++++++++++ js/main.js | 64 +++ js/ui.js | 1183 ++++++++++++++++++++++++++++++++++++++++++ js/world2d.js | 1106 +++++++++++++++++++++++++++++++++++++++ server.js | 22 + test/smoke.mjs | 412 +++++++++++++++ 14 files changed, 6244 insertions(+) create mode 100644 .gitignore create mode 100644 css/style.css create mode 100644 index.html create mode 100644 js/combat.js create mode 100644 js/data.dialogues.js create mode 100644 js/data.items.js create mode 100644 js/data.world.js create mode 100644 js/engine.js create mode 100644 js/game.js create mode 100644 js/main.js create mode 100644 js/ui.js create mode 100644 js/world2d.js create mode 100644 server.js create mode 100644 test/smoke.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1330247 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.log +.DS_Store diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..7af86d7 --- /dev/null +++ b/css/style.css @@ -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} +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..86478b0 --- /dev/null +++ b/index.html @@ -0,0 +1,48 @@ + + + + + +Jianghu Chronicles — Road of the Wandering Blade + + + +
+ + +
+
+

江湖 Jianghu Chronicles

+

Road of the Wandering Blade — an open-world wuxia RPG

+
+

An original browser homage to classic open-world martial-arts RPGs.
All names, characters and stories herein are original works of fiction.

+
+
+ + + + + + + + + + +
+ + + + + + + + + + + + diff --git a/js/combat.js b/js/combat.js new file mode 100644 index 0000000..903fadb --- /dev/null +++ b/js/combat.js @@ -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', `— Round ${this.round} —`); + 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 ${dmg} 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 ${dmg}.`); } + 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', `${Util.esc(u.name)} \u2014 ${Util.esc(mvd.n)} \u2014 ${Util.esc(t.name)} takes ${dmg}${crit ? ' CRIT!' : ''}`); + 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', `${Util.esc(t.name)} falls!`); + 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); } diff --git a/js/data.dialogues.js b/js/data.dialogues.js new file mode 100644 index 0000000..172c85d --- /dev/null +++ b/js/data.dialogues.js @@ -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 }] } +}}); diff --git a/js/data.items.js b/js/data.items.js new file mode 100644 index 0000000..ec91f6b --- /dev/null +++ b/js/data.items.js @@ -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.' }); diff --git a/js/data.world.js b/js/data.world.js new file mode 100644 index 0000000..03b1895 --- /dev/null +++ b/js/data.world.js @@ -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: 'Qiang’s lead token now sits in Captain Yan’s 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: 'Bai’s 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.' } +}); diff --git a/js/engine.js b/js/engine.js new file mode 100644 index 0000000..cf9ea9f --- /dev/null +++ b/js/engine.js @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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; } + } +}; diff --git a/js/game.js b/js/game.js new file mode 100644 index 0000000..f965600 --- /dev/null +++ b/js/game.js @@ -0,0 +1,1031 @@ +'use strict'; +/* ============================================================ + GAME — actions, progression, quests, economy, romance, endings + ============================================================ */ + +const Game = { + /* ---------------- new game ---------------- */ + newGame(opts) { + G.player = makePlayer(opts); + G.loc = 'l_village'; + G.discovered = { l_village: true }; + G.flags = { mainChapter: 1, ach: {}, seen: {}, fishTotal: 0 }; + if (opts.hard) G.flags.hardMode = true; + G.aff = {}; G.rep = { fame: 0, infamy: 0, azure: 0, temple: 0, serpent: 0, fist: 0, garrison: 0, underground: 0 }; + G.quests = {}; G.party = []; G.allies = []; + G.time = { day: 1, hour: 8 }; + G.log = []; + G.daily = defaultDaily(); + Game.rollBounties(); + Log.clearDom(); + Log.add(`Day 1 — ${G.player.name} steps out of Willow Creek Village. The jianghu awaits.`, 'sys'); + SaveSys.autosave(); + UI.showScreen('world'); + UI.setTab('scene'); + UI.refresh(); + UI.modal('Prologue', + `

Willow Creek never had use for heroes \u2014 until the wolves came down off Cloudmist Ridge,\nbold as tax collectors.\n\nYou are ${Util.esc(G.player.name)}, ${G.player.gender === 'f' ? 'daughter' : 'son'} of this village, and this morning\nyou wrapped your hands, checked your blade, and decided the wide road was calling.\n\nSpeak with Elder Chen to begin.

`, + [{ label: 'Begin the journey', fn: () => {} }]); + }, + + /* ---------------- inventory / money ---------------- */ + addItem(id, q) { + q = q || 1; + const it = DATA.ITEMS[id]; if (!it) return; + const stackable = !['weapon', 'body', 'head', 'feet'].includes(it.type); + const ex = stackable && G.player.inv.find(s => s.id === id); + if (ex) ex.q += q; else G.player.inv.push({ id, q }); + Log.add(`Obtained ${Util.esc(it.name)} ×${q}.`); + }, + removeItem(id, q) { + q = q || 1; + const slot = G.player.inv.find(s => s.id === id); + if (!slot) return false; + slot.q -= q; + if (slot.q <= 0) G.player.inv = G.player.inv.filter(s => s !== slot); + return true; + }, + hasItem(id, q) { q = q || 1; const s = G.player.inv.find(x => x.id === id); return !!s && s.q >= q; }, + countItem(id) { const s = G.player.inv.find(x => x.id === id); return s ? s.q : 0; }, + addSilver(n) { G.player.silver += n; Sfx.play('coin'); Log.add(`${n >= 0 ? 'Gained' : 'Lost'} ${Math.abs(n)} silver.`, n >= 0 ? 'good' : 'bad'); if (G.player.silver >= 2000) Game.unlockAch('fat_purse'); }, + spendSilver(n) { + if (G.player.silver < n) { Toast.show('Not enough silver.'); return false; } + G.player.silver -= n; Sfx.play('coin'); return true; + }, + + equip(slot, invIndex) { + const entry = G.player.inv[invIndex]; if (!entry) return; + const it = DATA.ITEMS[entry.id]; + const target = it.slot; + if (!target) return; + // weapons/armor are unique items; swap + const prev = G.player.equip[target]; + G.player.equip[target] = entry.id; + Game.removeItem(entry.id, 1); + if (prev) Game.addItem(prev, 1); + Stats.recalc(G.player); + G.player.hp = Math.min(G.player.hp, G.player.d.maxHp); + G.player.mp = Math.min(G.player.mp, G.player.d.maxMp); + Sfx.play('click'); UI.refresh(); + }, + unequip(slot) { + const cur = G.player.equip[slot]; if (!cur) return; + G.player.equip[slot] = null; + Game.addItem(cur, 1); + Stats.recalc(G.player); + UI.refresh(); + }, + + useItem(invIndex) { + const entry = G.player.inv[invIndex]; if (!entry) return; + const it = DATA.ITEMS[entry.id]; + if (it.type === 'manual') { Game.learnManual(entry.id); return; } + if (it.type !== 'use' || !it.use) { Toast.show('Cannot use that here.'); return; } + const u = it.use; const p = G.player; + if (u.hp) { p.hp = Math.min(p.d.maxHp, p.hp + u.hp); Log.add(`Restored ${u.hp} health.`, 'good'); } + if (u.mp) { p.mp = Math.min(p.d.maxMp, p.mp + u.mp); Log.add(`Restored ${u.mp} inner energy.`, 'good'); } + if (u.cure) { Toast.show('Use antidotes during battle.'); return; } + if (u.permHp) { if ((G.flags.tonicsUsed || 0) >= 10) { Toast.show('Your body can absorb no more tonic.'); return; } G.flags.tonicsUsed = (G.flags.tonicsUsed || 0) + 1; p.permBonuses.hp += u.permHp; Stats.recalc(p); Log.add(`Maximum health permanently +${u.permHp}!`, 'sys'); } + if (u.permWit) { if ((G.flags.fragsUsed || 0) >= 3) { Toast.show('The fragments blur together now.'); return; } G.flags.fragsUsed = (G.flags.fragsUsed || 0) + 1; p.attrs.wit += 1; Stats.recalc(p); Log.add('Insight deepens. Wit permanently +1!', 'sys'); } + if (u.buff || u.coatPoison) { Toast.show('Save it for battle.'); return; } + Game.removeItem(entry.id, 1); + Sfx.play('heal'); UI.refresh(); + }, + + learnManual(id) { + const it = DATA.ITEMS[id]; + const t = it.teach; + if (TECHNIQUES[t]) { + if (G.player.techs[t]) { Toast.show('You already know this art.'); return; } + Game.learnTechnique(t); + Game.removeItem(id, 1); + } else if (INTERNALS[t]) { + if (G.player.knownInternals.includes(t)) { Toast.show('Already cultivated.'); return; } + G.player.knownInternals.push(t); + G.player.internal = t; G.player.internalLv = Math.max(1, G.player.internalLv); + Stats.recalc(G.player); + Log.add(`You internalize the ${INTERNALS[t].name}.`, 'sys'); + if (t === 'i_sun') Game.unlockAch('sun_dawn'); + Game.removeItem(id, 1); UI.refresh(); + } else if (LIGHTNESS[t]) { + if (G.player.knownLights.includes(t)) { Toast.show('Already mastered.'); return; } + G.player.knownLights.push(t); G.player.light = t; + Stats.recalc(G.player); + Log.add(`Footwork transforms: ${LIGHTNESS[t].name}.`, 'sys'); + Game.removeItem(id, 1); UI.refresh(); + } + }, + + learnTechnique(tid, free) { + if (G.player.techs[tid]) return; + G.player.techs[tid] = 10; + Log.add(`Learned ${TECHNIQUES[tid].name}!`, 'sys'); + Sfx.play('levelup'); UI.refresh(); + }, + + /* ---------------- learning from masters ---------------- */ + teachListHas(npc, kind, id) { return (npc.teach || []).some(t => t[kind] === id); }, + alreadyKnows(t) { + if (t.tech) return !!G.player.techs[t.tech]; + if (t.internal) return G.player.knownInternals.includes(t.internal); + return false; + }, + canLearnFrom(npcId, t) { + const n = NPCS[npcId]; + const aff = G.aff[npcId] || 0; + if (aff < (t.aff || 0)) return { ok: false, why: `needs affection ${t.aff}` }; + if (t.reqRep && (G.rep[t.reqRep[0]] || 0) < t.reqRep[1]) return { ok: false, why: `needs ${t.reqRep[1]} ${t.reqRep[0]} rep` }; + if (t.reqFlag && !G.flags[t.reqFlag]) return { ok: false, why: 'a secret must surface first' }; + if (G.player.silver < (t.cost || 0)) return { ok: false, why: `${t.cost} silver` }; + return { ok: true, why: '' }; + }, + learnFrom(npcId, t) { + const ok = Game.canLearnFrom(npcId, t); + if (!ok.ok) { Toast.show(ok.why); return; } + if (!Game.spendSilver(t.cost || 0)) return; + if (t.tech) { G.player.techs[t.tech] = 10; Log.add(`${Util.esc(NPCS[npcId].name)} teaches you ${TECHNIQUES[t.tech].name}.`, 'sys'); } + if (t.internal) { + G.player.knownInternals.push(t.internal); G.player.internal = t.internal; + Log.add(`${Util.esc(NPCS[npcId].name)} transmits the ${INTERNALS[t.internal].name}.`, 'sys'); + if (t.internal === 'i_sun') Game.unlockAch('sun_dawn'); + } + Game.addAffinity(npcId, 3); + Sfx.play('levelup'); + SaveSys.autosave(); UI.refresh(); + }, + learnHongArt() { + G.player.techs['t_fist_4'] = 25; + G.flags.learnedHong = true; + Game.unlockAch('hong_heir'); + Log.add('Hong presses his palms together \u2014 ten thousand rivers pour into your bones. Learned Ten Thousand Rivers Palms!', 'sys'); + Sfx.play('levelup'); UI.refresh(); + }, + + /* ---------------- affinity & gifts ---------------- */ + addAffinity(npcId, n) { + G.aff[npcId] = Util.clamp((G.aff[npcId] || 0) + n, 0, 100); + UI.refreshTop && UI.refreshTop(); + }, + giveGift(npcId, itemId) { + const it = DATA.ITEMS[itemId]; + if (!Game.hasItem(itemId)) return; + let val = (it.val || 2) + Math.floor(G.player.attrs.cha / 8); + if (NPCS[npcId].giftTags && it.giftTags && it.giftTags.some(tg => NPCS[npcId].giftTags.includes(tg))) val += (it.likesVal || 3); + const givenToday = (G.daily.giftsGiven[npcId] || 0); + if (givenToday > 0) val = Math.ceil(val / (givenToday + 1)); + G.daily.giftsGiven[npcId] = givenToday + 1; + Game.removeItem(itemId, 1); + Game.addAffinity(npcId, val); + const reactions = [ + [50, 'is visibly moved by the gift.'], + [20, 'accepts with a warm smile.'], + [0, 'thanks you politely.'] + ]; + const aff = G.aff[npcId]; + const line = aff >= 80 ? 'treasures it like something irreplaceable.' : aff >= 50 ? reactions[0][1] : aff >= 20 ? reactions[1][1] : reactions[2][1]; + Log.add(`${Util.esc(NPCS[npcId].name)} ${line} (${NPCS[npcId].name.split(' ')[0]} +${val} affection)`, 'good'); + Sfx.play('coin'); UI.refresh(); + }, + useGiftOn(npcId, itemId) { Game.giveGift(npcId, itemId); }, + + addRep(fac, n) { + G.rep[fac] = Util.clamp((G.rep[fac] || 0) + n, -100, 100); + if (fac !== 'underground') G.rep.fame += Math.max(0, Math.round(n / 2)); else G.rep.infamy += Math.max(0, n); + UI.refreshTop && UI.refreshTop(); + }, + addFame(n) { G.rep.fame += n; UI.refreshTop && UI.refreshTop(); }, + addInfamy(n) { G.rep.infamy += n; UI.refreshTop && UI.refreshTop(); }, + + /* ---------------- achievements ---------------- */ + unlockAch(id) { + G.flags.ach = G.flags.ach || {}; + if (!ACHIEVEMENTS[id] || G.flags.ach[id]) return; + G.flags.ach[id] = true; + Toast.show(`🏆 Achievement: ${ACHIEVEMENTS[id].name}`); + Log.add(`🏆 Achievement unlocked: ${ACHIEVEMENTS[id].name}`, 'sys'); + Sfx.play('levelup'); + }, + + /* ---------------- EXP & levels ---------------- */ + addExp(n) { + const p = G.player; + p.exp += n; + Log.add(`Gained ${n} experience.`); + let leveled = false; + while (p.exp >= Stats.expNext(p.lvl) && p.lvl < 30) { + p.exp -= Stats.expNext(p.lvl); + p.lvl++; leveled = true; + G.flags.pendingAttr = (G.flags.pendingAttr || 0) + 2; + } + if (leveled) { + Stats.recalc(p); + p.hp = p.d.maxHp; p.mp = p.d.maxMp; + Log.add(`LEVEL UP! You are now level ${p.lvl}. (+2 attribute points)`, 'sys'); + Sfx.play('levelup'); + if (p.lvl >= 10) Game.unlockAch('journeyman'); + if (p.lvl >= 20) Game.unlockAch('master_qi'); + UI.openLevelUp && UI.openLevelUp(); + } + UI.refreshTop && UI.refreshTop(); + }, + spendAttrPoint(attr) { + if ((G.flags.pendingAttr || 0) <= 0) return; + G.player.attrs[attr]++; + G.flags.pendingAttr--; + Stats.recalc(G.player); + UI.refresh(); + if (!G.flags.pendingAttr) UI.closeModal && UI.closeModal(); + }, + + /* ---------------- travel & encounters ---------------- */ + travelTo(locId) { + if (G.loc === locId) return; + const edge = TRAVEL.find(e => (e[0] === G.loc && e[1] === locId) || (e[1] === G.loc && e[0] === locId)); + if (!edge) { Toast.show('No road leads there directly.'); return; } + const target = LOCS[locId]; + if (target.nightOnly && !TimeSys.isNight()) { Toast.show('That place only opens after dusk.'); return; } + if (target.hidden && !G.flags[target.hidden]) { Toast.show('You don\u2019t know how to get there yet.'); return; } + const hours = edge[2]; + TimeSys.advance(hours); + G.loc = locId; + G.discovered[locId] = true; + Log.add(`Traveled to ${Util.esc(target.name)}. (${TimeSys.str()})`, 'sys'); + SaveSys.autosave(); + UI.setTab('scene'); + Game.arriveEvents(locId); + UI.refresh(); + Game.rollEncounter(locId); + }, + + arriveEvents(locId) { + if (locId === 'l_market' && QSsafe('q_main_3') === 0) { Game.setStage('q_main_3', 1); } + if (locId === 'l_market') Game.unlockAch('after_dark'); + if (locId === 'l_city' && !G.flags.hongSeen && G.time.day >= 4 && Util.chance(0.5)) { G.flags.hongSeen = true; Toast.show('A drunk beggar sings badly by the wine shops\u2026'); } + if (locId === 'l_mountain' && FVsafe('mainChapter') >= 4 && !G.flags.tombroute) { G.flags.tombroute = true; Toast.show('Behind the old shrine, buried stairs descend into darkness\u2026 (Ancient Tomb discovered)'); G.discovered['l_tomb'] = true; } + if (locId === 'l_tomb' && QSsafe('q_main_5') === 0) Game.setStage('q_main_5', 1); + }, + + rollEncounter(locId) { + const L = LOCS[locId]; + if (!L.danger) return; + if (Util.chance(0.10 + L.danger * 0.08)) Game.wildEncounter(locId); + }, + + wildEncounter(locId) { + const L = LOCS[locId]; + const plvl = G.player.lvl; + const pool = plvl <= 4 ? (L.pools.low || L.pools.mid) : plvl <= 9 ? (L.pools.mid || L.pools.high) : (L.pools.high || L.pools.mid); + if (!pool) return; + const count = Util.chance(0.65) ? 2 : 1; + const scale = 0.85 + Math.min(0.5, plvl * 0.045); + const enemies = []; + for (let i = 0; i < count; i++) enemies.push(Util.pick(pool)); + Game.startBattle({ ids: enemies, scale, meta: { kind: 'wild', locName: L.name } }); + }, + + /* ---------------- battles ---------------- */ + startBattle(spec) { + if (G.inBattle) return; + const plvl = G.player.lvl; + let scale = spec.scale || 1; + if (G.flags.hardMode) scale *= 1.28; + let enemies; + if (spec.customEnemies && spec.customEnemies.length) { + enemies = spec.customEnemies.map(u => { u.side = 'enemy'; return u; }); + enemies.forEach(e => { e.expVal = e.expVal || 0; e.silver = null; }); + } else { + const ids = spec.ids || []; + enemies = ids.map(id => makeEnemyUnit(id, scale)).filter(Boolean); + enemies.forEach((e, i) => { e.enemyId = ids[i]; }); + } + if (!enemies.length) return; + + // bestiary tracking + G.flags.seen = G.flags.seen || {}; + enemies.forEach(e => { G.flags.seen[e.enemyId || e.name] = true; }); + + const allies = [makePlayerUnit(G.player)]; + for (const cid of G.party.slice(0, 2)) { + const cu = makeCompanionUnit(cid, plvl); + if (cu) allies.push(cu); + } + + const meta = Object.assign({ kind: 'wild' }, spec.meta || {}); + if (meta.spar) { + // remember pre-battle vitals to restore afterwards + meta.preHp = G.player.hp; meta.preMp = G.player.mp; + } + G.battleMeta = meta; + G.inBattle = true; + + const battle = new Battle({ + allies, enemies, + instant: !!spec.instant, + spar: !!meta.spar, + noDeath: !!meta.spar + }); + battle.on('log', (m) => Log.add(m, 'combat')); + battle.on('end', (res) => Game.afterBattle(res, battle)); + G.battle = battle; + if (typeof document !== 'undefined' && UI.openBattle) UI.openBattle(battle); + else battle.startRound(); + return battle; + }, + + registerKill(unit) { + G.flags.totalKills = (G.flags.totalKills || 0) + 1; + G.flags.kills = G.flags.kills || {}; + const kid = unit.enemyId || unit.name; + G.flags.kills[kid] = (G.flags.kills[kid] || 0) + 1; + Game.unlockAch('first_blood'); + const meta = G.battleMeta || {}; + if (meta.kind === 'wolfhunt' && unit.enemyId === 'e_wolf') G.flags.wolfKills = (G.flags.wolfKills || 0) + 1; + if (meta.kind === 'tomb' && unit.enemyId === 'e_sentinel') G.flags.sentinelKills = (G.flags.sentinelKills || 0) + 1; + }, + + afterBattle(res, battle) { + G._lastRes = res; + const meta = G.battleMeta || {}; + G.inBattle = false; + const p = G.player; + + if (meta.spar) { + // friendly: restore vitals, grant proficiency & affection + p.hp = Math.min(meta.preHp, p.d.maxHp); p.mp = Math.min(meta.preMp, p.d.maxMp); + if (res.win) { + for (const tid of Object.keys(p.techs)) p.techs[tid] = Math.min(100, p.techs[tid] + 3); + if (meta.npcId) { Game.addAffinity(meta.npcId, res.win ? 4 : 2); G.daily.sparred[meta.npcId] = true; } + Log.add('A clean exchange! Techniques sharpen. (+3 proficiency, +4 affection)', 'good'); + if (meta.npcId === 'n_beggar_hong') G.flags.hongBeats = (G.flags.hongBeats || 0) + 1; + if (meta.npcId === 'n_monk_jing') { + const qf = G.quests['q_trial_fl']; + if (qf && !qf.done && qf.stage === 0 && !G.flags.trialFlSpar) { + G.flags.trialFlSpar = true; + Log.add('Monk Jing bows. \u201cYour staff speaks politely. Good.\u201d', 'sys'); + Game.checkTrialFl(); + } + } + if (meta.onWin) meta.onWin(); + } else { + if (meta.npcId) Game.addAffinity(meta.npcId, 2); + Log.add('Defeated \u2014 but every bruise is a lesson.', 'bad'); + } + G.battleMeta = null; G.battle = null; + UI.closeBattle && UI.closeBattle(); + UI.refresh(); + SaveSys.autosave(); + return; + } + + if (res.win) { + let exp = 0, silver = 0; + const loots = []; + for (const e of battle.enemies) { + exp += e.expVal || 20; + if (e.silver) silver += Util.ri(e.silver[0], e.silver[1]); + for (const l of (e.loot || [])) if (Util.chance(l.ch)) loots.push([l.id, l.q || 1]); + } + // luck bonus + if (Util.chance(G.player.attrs.luk * 0.01)) { loots.push(['c_pill_hp', 1]); Log.add('Fortune smiles \u2014 extra loot!', 'good'); } + Game.addSilver(silver); + Game.addExp(exp); + for (const [id, q] of loots) Game.addItem(id, q); + // proficiency from techniques used + for (const [tid, uses] of Object.entries(res.techUses || {})) { + p.techs[tid] = Math.min(100, (p.techs[tid] || 0) + Math.min(6, uses * 2)); + } + if (meta.kind === 'wild') Game.addFame(1); + } else { + Game.defeat(meta); + } + + // tear down battle state BEFORE onWin so chained battles open cleanly + G.battleMeta = null; G.battle = null; G.inBattle = false; + UI.closeBattle && UI.closeBattle(); + if (res.win && meta.onWin) meta.onWin(); + UI.refresh(); + if (p.hp > 0) SaveSys.autosave(); + }, + + defeat(meta) { + const p = G.player; + const loss = Math.round(p.silver * 0.1); + p.silver -= loss; + Log.add(`Darkness takes you\u2026 You awaken at an inn, lighter by ${loss} silver.`, 'bad'); + Sfx.play('defeat'); + p.hp = Math.max(1, Math.round(p.d.maxHp * 0.5)); + p.mp = Math.max(0, Math.round(p.d.maxMp * 0.5)); + // respawn at nearest safe town + G.loc = ['l_village', 'l_city', 'l_town'].includes(G.loc) ? G.loc : 'l_city'; + G.time.hour = 8; G.time.day += 1; + Game.dailyReset(); + }, + + /* ---------------- rest & cultivation ---------------- */ + restInn(cost) { + if (!Game.spendSilver(cost)) return; + const p = G.player; + p.hp = p.d.maxHp; p.mp = p.d.maxMp; + const hoursToEight = ((24 - G.time.hour) + 8) % 24 || 24; + TimeSys.advance(hoursToEight); + Log.add('You sleep deeply and wake restored.', 'good'); + SaveSys.autosave(); + UI.closeModal && UI.closeModal(); + UI.refresh(); + }, + campOut() { + const L = LOCS[G.loc]; + if (!L.danger) { Toast.show('Camp near town? Just sleep at the inn.'); return; } + TimeSys.advance(6); + const p = G.player; + p.hp = Math.min(p.d.maxHp, p.hp + Math.round(p.d.maxHp * 0.55)); + p.mp = Math.min(p.d.maxMp, p.mp + Math.round(p.d.maxMp * 0.55)); + Log.add('You camp beneath cold stars.', 'sys'); + if (Util.chance(0.18)) { Log.add('Something stalks the campfire light\u2026', 'bad'); Game.wildEncounter(G.loc); return; } + UI.refresh(); + }, + meditate() { + TimeSys.advance(2); + const p = G.player; + const gain = 8 + p.attrs.wit * 2 + (LOCS[G.loc].actions || []).some(a => a.id === 'meditate' && G.loc === 'l_temple' ? 4 : 0); + p.internalExp += gain; + Log.add(`You circulate qi quietly. (+${gain} internal insight)`); + // temple trial progress + const qf = G.quests['q_trial_fl']; + if (qf && !qf.done && qf.stage === 0 && !G.flags.trialFlMed) { + G.flags.trialFlMed = true; + Log.add('The incense clock shows one full sitting. Patience, practiced.', 'sys'); + Game.checkTrialFl(); + } + const need = p.internalLv * 55; + if (p.internalExp >= need && p.internalLv < 9) { + p.internalExp -= need; p.internalLv++; + Stats.recalc(p); + Log.add(`${UTIL_INT_NAME(p.internal)} reaches level ${p.internalLv}!`, 'sys'); + Sfx.play('levelup'); + } + UI.refresh(); + }, + trainDummy() { + if ((G.daily.dummyPts || 0) >= 15) { Toast.show('The posts have taught you all they can today.'); return; } + UI.openTrainPick(); + }, + doTrainDummy(tid) { + TimeSys.advance(1); + const amt = Math.min(3, 15 - (G.daily.dummyPts || 0)); + G.daily.dummyPts = (G.daily.dummyPts || 0) + amt; + G.player.techs[tid] = Math.min(100, (G.player.techs[tid] || 0) + amt); + Log.add(`Drilled ${TECHNIQUES[tid].name} against the posts. (+${amt} proficiency)`); + UI.closeModal && UI.closeModal(); + UI.refresh(); + }, + + /* ---------------- location facilities ---------------- */ + gatherHerbs() { + const key = 'herb_' + G.loc; + if (G.daily.gathered[key]) { Toast.show('Picked clean for today.'); return; } + G.daily.gathered[key] = true; + TimeSys.advance(1); + const loc = G.loc; + if (loc === 'l_valley') { + Game.addItem('c_lotus', Util.ri(1, 2)); + if (Util.chance(0.35)) Game.addItem('mat_gall', 1); + if (Util.chance(0.2)) Game.addItem('c_herb_red', 1); + } else if (loc === 'l_bamboo') { + Game.addItem('c_herb_green', Util.ri(1, 2)); + if (Util.chance(0.3)) Game.addItem('c_lotus', 1); + } else { + Game.addItem('c_herb_green', Util.ri(1, 2)); + if (Util.chance(0.3)) Game.addItem('c_herb_red', 1); + } + UI.refresh(); + }, + prospectMine() { + if (G.daily.mined) { Toast.show('The seam is picked over for today.'); return; } + G.daily.mined = true; + TimeSys.advance(2); + G.flags.oreRuns = (G.flags.oreRuns || 0) + 1; + Game.addItem('mat_ore_1', Util.ri(1, 2)); + if (Util.chance(0.3 + G.player.attrs.luk * 0.01)) Game.addItem('mat_ore_2', 1); + if (Util.chance(0.04 + G.player.attrs.luk * 0.004)) { Game.addItem('mat_ore_3', 1); Log.add('Star-metal glints in the rubble!', 'good'); } + UI.refresh(); + }, + sectTasks() { + if (!G.flags.azureMember) { Toast.show('Only Azure Cloud disciples may take missions.'); return; } + const opts = [ + { label: 'Patrol the cloud stairs (fight)', fn: () => { UI.closeModal(); Game.startBattle({ ids: ['e_bandit_vet'], scale: 1, meta: { kind: 'sect', onWin: () => { Game.addRep('azure', 8); Game.addSilver(40); } } }); } }, + { label: 'Courier scrolls to the temple (2h)', fn: () => { TimeSys.advance(2); Game.addRep('azure', 6); Log.add('Scrolls delivered; the brothers nod approvingly. (+6 azure rep)', 'good'); UI.refresh(); } }, + { label: 'Demonstrate forms for juniors (1h)', fn: () => { TimeSys.advance(1); Game.addRep('azure', 5); Game.addExp(20); UI.refresh(); } }, + { label: 'Not today', fn: () => UI.closeModal() } + ]; + UI.modal('Mission Hall', '

A bored steward slides three wooden tallies across the desk.

', opts.map(o => ({ label: o.label, fn: o.fn }))); + }, + + /* ---------------- economy facilities ---------------- */ + buyItem(npcId, itemId) { + const n = NPCS[npcId]; if (!n.shop) return; + const it = DATA.ITEMS[itemId]; + const price = Game.buyPrice(it); + if (!Game.spendSilver(price)) return; + Game.addItem(itemId, 1); + UI.refreshModalIf && UI.refreshModalIf(); + }, + sellItem(itemId) { + const it = DATA.ITEMS[itemId]; + const price = Game.sellPrice(it); + if (!Game.hasItem(itemId)) return; + Game.removeItem(itemId, 1); + Game.addSilver(price); + UI.refreshModalIf && UI.refreshModalIf(); + }, + buyPrice(it) { + const disc = Util.clamp(G.player.attrs.cha * 0.012, 0, 0.15) + ((G.rep.underground > 20 && LOCS[G.loc] === LOCS.l_market) ? 0.05 : 0); + return Math.max(1, Math.round(it.price * (1 - disc))); + }, + sellPrice(it) { return Math.max(1, Math.floor(it.price * 0.42 * (1 + G.player.attrs.cha * 0.008))); }, + + smithUpgrade(fromId) { + const up = SMITH_UPGRADES[fromId]; if (!up) return; + if (!Game.hasItem(fromId) && !Object.values(G.player.equip).includes(fromId)) { Toast.show('You don\u2019t have that item.'); return; } + for (const [m, q] of Object.entries(up.mats)) if (!Game.hasItem(m, q)) { Toast.show('Missing materials.'); return; } + if (!Game.spendSilver(up.silver)) return; + for (const [m, q] of Object.entries(up.mats)) Game.removeItem(m, q); + // consume owned copy (equipped preferred) + if (Object.values(G.player.equip).includes(fromId)) { + for (const s of Object.keys(G.player.equip)) if (G.player.equip[s] === fromId) { G.player.equip[s] = null; break; } + } else Game.removeItem(fromId, 1); + Game.addItem(up.to, 1); + Log.add(`The forge roars \u2014 crafted ${DATA.ITEMS[up.to].name}!`, 'sys'); + Sfx.play('levelup'); + Stats.recalc(G.player); + UI.openSmith(); // refresh panel + }, + brew(recipeId) { + const r = ALCHEMY_RECIPES.find(x => x.id === recipeId); if (!r) return; + if (r.needFlag && !G.flags[r.needFlag]) { Toast.show('You don\u2019t know that recipe.'); return; } + for (const [m, q] of Object.entries(r.mats)) if (!Game.hasItem(m, q)) { Toast.show('Missing ingredients.'); return; } + if (!Game.spendSilver(r.silver)) return; + for (const [m, q] of Object.entries(r.mats)) Game.removeItem(m, q); + Game.addItem(r.out, r.qty); + Log.add(`Brewed ${DATA.ITEMS[r.out].name} ×${r.qty}.`, 'good'); + UI.openAlchemy(); + }, + + /* ---------------- gambling & crime ---------------- */ + gambleBet(kind, amount) { + if (!Game.spendSilver(amount)) return null; + const dice = [Util.ri(1, 6), Util.ri(1, 6), Util.ri(1, 6)]; + const sum = dice[0] + dice[1] + dice[2]; + const triple = dice[0] === dice[1] && dice[1] === dice[2]; + let win = 0; + if (kind === 'triple') { if (triple) win = amount * 28; } + else if (kind === 'big') { if (!triple && sum >= 11) win = Math.floor(amount * 1.9); } + else if (kind === 'small') { if (!triple && sum <= 10) win = Math.floor(amount * 1.9); } + if (win) Game.addSilver(win); + return { dice, sum, triple, win }; + }, + pickpocket() { + if (G.daily.pickpocket) { Toast.show('Guards remember faces. Tomorrow, perhaps.'); return; } + G.daily.pickpocket = true; + TimeSys.advance(1); + const p = G.player; + const chance = Util.clamp(0.32 + p.attrs.agi * 0.03 + p.attrs.luk * 0.02 - (G.loc === 'l_market' ? 0.06 : 0), 0.15, 0.75); + if (Util.chance(chance)) { + const take = Util.ri(15, 70); + Game.addSilver(take); + Log.add('Light fingers, lighter purse. Nobody notices.', 'good'); + } else { + Game.addInfamy(2); + const fine = Math.min(p.silver, 30); + p.silver -= fine; + Log.add(`Caught! A guard confiscates ${fine} silver and your dignity. (+2 infamy)`, 'bad'); + } + UI.refresh(); + }, + + /* ---------------- fishing ---------------- */ + /* ---------------- side jobs (job board postings) ---------------- */ + SIDE_JOBS: { + q_herb_bai: { + give: () => `Deliver 5 Red Herb to Alchemist Bai\u2019s shelf.`, + prog: () => `${Math.min(Game.countItem('c_herb_red'), 5)}/5 Red Herb`, + ready: () => Game.hasItem('c_herb_red', 5), + take: () => Game.removeItem('c_herb_red', 5), + rw: { exp: 120, silver: 140, items: [['c_pill_hp', 2]], aff: [['n_alchemist_bai', 8]] } + }, + q_ring_reeds: { + give: () => `Cull 4 serpents in Serpent Veil Valley; one swallowed a ring.`, + prog: () => `${Math.min((G.flags.kills || {}).e_snake || 0, 4)}/4 serpents`, + ready: () => ((G.flags.kills || {}).e_snake || 0) >= 4, + take: () => {}, + rw: { exp: 130, silver: 180, fame: 3, items: [['ac_rope', 1]] } + }, + q_forge_coal: { + give: () => `Prospect ore twice at Cloudmist Ridge for Smith Wang.`, + prog: () => `${Math.min(G.flags.oreRuns || 0, 2)}/2 expeditions`, + ready: () => (G.flags.oreRuns || 0) >= 2, + take: () => {}, + rw: { exp: 110, silver: 160, items: [['mat_ore_1', 3]] } + }, + q_night_watch: { + give: () => `Cull 3 bandits on the roads. Wage on delivery.`, + prog: () => `${Math.min(((G.flags.kills || {}).e_bandit || 0) + ((G.flags.kills || {}).e_bandit_vet || 0), 3)}/3 bandits`, + ready: () => (((G.flags.kills || {}).e_bandit || 0) + ((G.flags.kills || {}).e_bandit_vet || 0)) >= 3, + take: () => {}, + rw: { exp: 150, silver: 220, rep: [['garrison', 15]] } + }, + q_alms_lotus: { + give: () => `Donate 300 silver to the temple restoration fund.`, + prog: () => `300 silver required`, + ready: () => G.player.silver >= 300, + take: () => { G.player.silver -= 300; Log.add('You seal 300 silver into the temple fund box.', 'bad'); }, + rw: { exp: 140, silver: 0, rep: [['temple', 20]] } + }, + q_lantern_dead: { + give: () => `Bring 2 Mulled Wine and 1 Lotus Herb for the river rite.`, + prog: () => `${Math.min(Game.countItem('c_wine'), 2)}/2 wine · ${Math.min(Game.countItem('c_lotus'), 1)}/1 lotus`, + ready: () => Game.hasItem('c_wine', 2) && Game.hasItem('c_lotus', 1), + take: () => { Game.removeItem('c_wine', 2); Game.removeItem('c_lotus', 1); }, + rw: { exp: 100, silver: 90, fame: 5, items: [['c_antidote', 2]] } + } + }, + + sideState(qid) { + const q = G.quests[qid]; + if (!q) return 'offer'; + return q.done ? 'done' : (((this.SIDE_JOBS[qid] || {}).ready && this.SIDE_JOBS[qid].ready()) ? 'ready' : 'active'); + }, + sideStart(qid) { + if (G.quests[qid]) return; + Game.startQuest(qid); + Toast.show(`Job accepted: ${QUESTS[qid].name}`); + }, + sideDeliver(qid) { + const job = this.SIDE_JOBS[qid]; + const q = G.quests[qid]; + if (!job || !q || q.done || !job.ready()) return; + job.take(); + Game.completeQuest(qid, {}); + const rw = job.rw || {}; + if (rw.exp) Game.addExp(rw.exp); + if (rw.silver) Game.addSilver(rw.silver); + if (rw.fame) Game.addFame(rw.fame); + if (rw.rep) rw.rep.forEach(([f, n]) => Game.addRep(f, n)); + if (rw.aff) rw.aff.forEach(([n, v]) => Game.addAffinity(n, v)); + if (rw.items) rw.items.forEach(([i, n2]) => Game.addItem(i, n2)); + Sfx.play('coin'); + UI.refresh(); + }, + + canFish() { return (G.daily.fished || 0) < 10; }, + fishResult(hit) { + G.daily.fished = (G.daily.fished || 0) + 1; + G.flags.fishTotal = (G.flags.fishTotal || 0) + 1; + if ((G.flags.fishTotal || 0) >= 25) Game.unlockAch('water_patience'); + if (hit) { + if (Util.chance(0.15)) { Game.addItem('c_fish', 2); Log.add('A double catch thrashes in the basket!', 'good'); } + else Game.addItem('c_fish', 1); + Sfx.play('coin'); + } else if (Util.chance(0.5)) { + Game.addItem('j_boot', 1); + Log.add('You reel in\u2026 a boot. The river keeps its secrets.', 'bad'); + } else { + Log.add('The carp mocks your reflexes and departs with dignity.'); + } + }, + + /* ---------------- bounties ---------------- */ + rollBounties() { + const plvl = G.player.lvl; + const tier = plvl <= 4 ? 1 : plvl <= 8 ? 2 : 3; + const pool = BOUNTY_POOLS[tier]; + const list = []; + for (let i = 0; i < 3; i++) { + const id = Util.pick(pool); + const e = ENEMIES[id]; + const cnt = e.tier <= 1 ? Util.ri(2, 3) : 1; + const silver = Util.ri(30, 60) * e.tier * cnt; + list.push({ id, count: cnt, silver, exp: Math.round((e.exp || 30) * 1.4 * cnt), fame: 4 * e.tier }); + } + G.bounty.list = list; G.bounty.taken = null; + }, + takeBounty(i) { + if (G.bounty.taken !== null) { Toast.show('Finish your current contract first.'); return; } + G.bounty.taken = i; + Toast.show('Contract accepted. Hunt them via the board.'); + UI.refresh(); + }, + huntBounty() { + const b = G.bounty.list[G.bounty.taken]; + if (!b) return; + const scale = 0.9 + G.player.lvl * 0.03; + const ids = []; for (let i = 0; i < b.count; i++) ids.push(b.id); + Game.startBattle({ ids, scale, meta: { kind: 'bounty', onWin: () => { + Game.addSilver(b.silver); Game.addExp(b.exp); Game.addFame(b.fame); + G.bounty.list.splice(G.bounty.taken, 1); + if (!G.bounty.list.length) Game.rollBounties(); + G.bounty.taken = null; + Log.add('Bounty claimed. Justice, monetized.', 'sys'); + } } }); + }, + + /* ---------------- tournament ---------------- */ + isTournamentDay() { return G.time.day % 7 === 5; }, + enterTournament() { + if (!Game.isTournamentDay()) return; + G.tourney.lastDay = G.time.day; + G.tourney.stage = 0; + TimeSys.advance(1); + Game.nextTourneyFight(); + }, + nextTourneyFight() { + const st = G.tourney.stage || 0; + if (st >= TOURNEY_LADDER.length) { Toast.show('You have already conquered this week\u2019s bracket.'); return; } + const rung = TOURNEY_LADDER[st]; + const scale = 0.9 + G.player.lvl * 0.05; + Game.startBattle({ ids: [rung.base], scale, meta: { kind: 'arena', name: rung.name, onWin: () => Game.tourneyWin() } }); + }, + tourneyWin() { + G.tourney.stage = (G.tourney.stage || 0) + 1; + const p = G.player; + p.hp = Math.min(p.d.maxHp, p.hp + Math.round(p.d.maxHp * 0.45)); + p.mp = Math.min(p.d.maxMp, p.mp + Math.round(p.d.maxMp * 0.45)); + const prize = [80, 150, 250, 500][G.tourney.stage - 1] || 100; + Game.addSilver(prize); + Game.addFame(6); + if (G.tourney.stage >= TOURNEY_LADDER.length) { + Game.addFame(20); + G.flags.champion = true; + Game.unlockAch('champion'); + Log.add('CHAMPION! The crowd roars your name across Qingyun!', 'sys'); + Sfx.play('victory'); + if (QSsafe('q_main_3') === 2) { Game.setStage('q_main_3', 3); Toast.show('Host Qian waves you over urgently\u2026'); } + } else { + Log.add(`Victory in round ${G.tourney.stage}! Prize: ${prize} silver.`, 'good'); + UI.modal('Tournament', `

Round ${G.tourney.stage} won! Your next opponent awaits: ${TOURNEY_LADDER[G.tourney.stage].name}.

`, + [{ label: 'Fight on', fn: () => { UI.closeModal(); Game.nextTourneyFight(); } }, { label: 'Withdraw (keep winnings)', fn: () => UI.closeModal() }]); + } + }, + dukangAmbush() { + UI.modal('The Pavilion', '

Behind the arena, a man waits beneath a silver-paper umbrella. Du Kang smiles without warmth.

\u201cThe celebrated ridge-clearer! The Society offers glory, wealth\u2026 and long life. Refuse, and become an anecdote.\u201d

', + [ + { label: 'Refuse. Draw your weapon.', fn: () => { UI.closeModal(); Game.fightDukang(false); } }, + { label: '"Tell me more\u2026" (feign interest)', fn: () => { UI.closeModal(); UI.modal('Du Kang leans in\u2026', '

\u201cWise. The Ledger in the old tomb \u2014 bring it to us, and names, ranks, silver follow. Fail, and\u2026 well.\u201d

You could play along\u2026

', + [ { label: 'Accept the dark mark (villain path)', fn: () => { UI.closeModal(); G.flags.villainPath = true; Game.fightDukang(true); } }, + { label: 'PSYCH \u2014 attack!', fn: () => { UI.closeModal(); Game.fightDukang(false); } } ]); } } + ]); + }, + fightDukang(joined) { + Game.startBattle({ ids: ['e_boss_dukang'], scale: 1, meta: { kind: 'boss', boss: 'dukang', joined, onWin: () => { + Game.completeQuest('q_main_3', { exp: 350, silver: 300, fame: 30 }); + G.flags.mainChapter = 3; + Game.startQuest('q_main_4'); + Log.add(joined ? 'Du Kang bleeds anyway \u2014 \u201cConsider\u2026 the mark earned.\u201d He vanishes.' : 'Du Kang flees, umbrella shattered, secrets spilled.', 'sys'); + } } }); + }, + + /* ---------------- companions ---------------- */ + recruitCompanion(npcId) { + if (!G.allies.includes(npcId)) G.allies.push(npcId); + if (G.party.length < 2 && !G.party.includes(npcId)) G.party.push(npcId); + Log.add(`${Util.esc(NPCS[npcId].name)} joins you!`, 'sys'); + Sfx.play('victory'); + if (G.allies.length >= 3) Game.unlockAch('band_three'); + Game.checkQuestAuto(); + SaveSys.autosave(); UI.refresh(); + }, + togglePartyMember(npcId) { + if (G.party.includes(npcId)) { G.party = G.party.filter(x => x !== npcId); } + else if (G.party.length < 2) G.party.push(npcId); + else { Toast.show('Only two may walk beside you in battle.'); return; } + UI.refresh(); + }, + confess(npcId) { + G.partner = npcId; + G.aff[npcId] = 100; + Game.recruitCompanion(npcId); + Game.unlockAch('two_rivers'); + Log.add('\u2764 Your hearts align beneath the clouds. (Romance unlocked)', 'sys'); + }, + canSpar(npcId) { return !!NPCS[npcId].spar && !G.daily.sparred[npcId]; }, + spar(npcId) { + if (!Game.canSpar(npcId)) return; + const n = NPCS[npcId]; + TimeSys.advance(1); + // build sparring opponent + const plvl = G.player.lvl; + let opp; + if (n.companion) opp = makeCompanionUnit(npcId, plvl + 1); + else { + const wtMap = { 'n_abbot_lian': 'staff', 'n_master_yun': 'sword', 'n_lady_miao': 'hidden', 'n_master_guo': 'fist', 'n_monk_jing': 'staff', 'n_hunter_ren': 'blade', 'n_beggar_hong': 'fist' }; + const wt = wtMap[npcId] || 'fist'; + const hp = 90 + plvl * 16; + opp = makeUnit({ + name: n.name, side: 'enemy', + hp, hpMax: hp, mp: 60, mpMax: 60, + atk: 8 + plvl * 2.4, def: 4 + plvl * 0.8, spd: 9 + plvl * 0.5, + crit: 8, dodge: 8, mv: 4, rng: wt === 'hidden' ? 4 : 1, wt, + moves: [{ n: 'Measured Strike', mp: 0, p: 118, r: 1, sh: 'foe' }], + glyph: n.char, color: n.hue, ai: 'duelist' + }); + } + opp.side = 'enemy'; + Game.startBattle({ + alliesOverride: null, + ids: [], customEnemies: [opp], + meta: { kind: 'spar', spar: true, npcId } + }); + }, + drinkingContest() { + if (!Game.spendSilver(20)) return; + TimeSys.advance(1); + const roll = Util.ri(10, 90) + G.player.attrs.con * 5 + G.player.attrs.wit * 2; + if (roll >= 85) { + Game.addAffinity('n_tiehu', 8); + Game.addSilver(40); + Log.add('Tiehu slams his cup down, swaying. \u201cYou\u2019re\u2026 a monster. Remind me to never fight you sober.\u201d (+8 affection, +40 silver)', 'good'); + } else { + G.player.hp = Math.max(1, G.player.hp - Math.round(G.player.d.maxHp * 0.08)); + Log.add('The room spins like a kicking horse. Tiehu laughs and steals your stake.', 'bad'); + } + UI.refresh(); + }, + linDuel() { + const plvl = G.player.lvl; + const hp = 110 + plvl * 17; + const opp = makeUnit({ + name: 'Lin Wan\u2019er', side: 'enemy', + hp, hpMax: hp, mp: 80, mpMax: 80, + atk: 10 + plvl * 2.7, def: 4 + plvl * 0.9, spd: 11 + plvl * 0.6, + crit: 12, dodge: 10, mv: 5, rng: 1, wt: 'sword', + moves: [{ n: 'Cloud Ripple Thrust', mp: 0, p: 128, r: 1, sh: 'foe' }, { n: 'Moonveil Counter', mp: 10, p: 150, r: 1, sh: 'foe' }], + glyph: NPCS.n_lin.char, color: NPCS.n_lin.hue, ai: 'duelist' + }); + Game.startBattle({ ids: [], customEnemies: [opp], meta: { kind: 'spar', spar: true, npcId: 'n_lin', onWin: () => { + Log.add('Wan\u2019er lowers her sword, breathing hard, eyes bright. \u201c\u2026Fine. The roads are boring alone anyway.\u201d', 'sys'); + Game.recruitCompanion('n_lin'); + Game.addAffinity('n_lin', 10); + } } }); + }, + + /* ---------------- story beats ---------------- */ + startQuest(qid) { + if (G.quests[qid] && !G.quests[qid].done) return; + G.quests[qid] = { stage: 0, done: false }; + Log.add(`New quest: ${QUESTS[qid].name}`, 'sys'); + UI.toastSafe && UI.toastSafe(`Quest accepted: ${QUESTS[qid].name}`); + SaveSys.autosave(); + }, + setStage(qid, st) { + const q = G.quests[qid]; if (!q || q.done) return; + q.stage = st; + Log.add(`Journal updated: ${QUESTS[qid].name}`, 'sys'); + UI.toastSafe && UI.toastSafe(`Journal updated: ${QUESTS[qid].name}`); + SaveSys.autosave(); + }, + completeQuest(qid, rw) { + const q = G.quests[qid]; if (!q || q.done) return; + q.done = true; + rw = rw || {}; + if (qid === 'q_main_1') Game.unlockAch('ridge_guardian'); + if (qid === 'q_main_6') Game.unlockAch('umbrellas_fall'); + Log.add(`Quest complete: ${QUESTS[qid].name}`, 'sys'); + if (rw.exp) Game.addExp(rw.exp); + if (rw.silver) Game.addSilver(rw.silver); + if (rw.fame) Game.addFame(rw.fame); + for (const [id, n] of (rw.items || [])) Game.addItem(id, n); + for (const [fid, n] of (rw.aff || [])) Game.addAffinity(fid, n); + for (const [fac, n] of (rw.rep || [])) Game.addRep(fac, n); + Sfx.play('victory'); + SaveSys.autosave(); + Game.checkQuestAuto(); + UI.refresh(); + }, + questStageText(qid) { + const q = G.quests[qid]; if (!q) return null; + const def = QUESTS[qid]; + if (q.done) return def.doneText || 'Complete.'; + let txt = def.stages[q.stage] || ''; + txt = txt.replace('%K/wolves', `${Math.min(G.flags.wolfKills || 0, 3)}/3`); + txt = txt.replace('%K/sentinels', `${Math.min(G.flags.sentinelKills || 0, 2)}/2`); + return txt; + }, + checkQuestAuto() { + const q4 = G.quests['q_main_4']; + if (q4 && !q4.done && q4.stage === 0 && G.allies.length >= 2) Game.setStage('q_main_4', 1); + if (q4 && !q4.done && q4.stage === 1) { + if (G.flags.azureMember || G.flags.fistMember || G.flags.serpentMember || G.flags.templeMember || (G.rep.garrison || 0) >= 30) Game.setStage('q_main_4', 2); + } + }, + checkTrialFl() { + const qf = G.quests['q_trial_fl']; + if (!qf || qf.done || qf.stage !== 0) return; + if (G.flags.trialFlMed && G.flags.trialFlSpar) { + Game.setStage('q_trial_fl', 1); + Log.add('Swept courtyard, stilled breath, friendly bruises — the trial feels complete.', 'sys'); + } + }, + + searchDeepBamboo() { + Game.startBattle({ ids: ['e_wolf', 'e_wolf', 'e_tiger'], scale: 0.95, meta: { kind: 'quest', onWin: () => { + Log.add('Deep in the black bamboo you find Uncle Ping\u2019s camp \u2014 cold ashes, and a carved walking stick you recognize.', 'sys'); + Game.setStage('q_missing', 2); + } } }); + }, + banditCampAction() { + const q = G.quests['q_main_2']; if (!q || q.done) return; + if (q.stage === 1 || q.stage === 2) { + Game.startBattle({ ids: ['e_bandit', 'e_bandit_vet', 'e_boss_qiang'], scale: 1, meta: { kind: 'boss', boss: 'qiang', onWin: () => { + Game.setStage('q_main_2', 3); + } } }); + } else if (q.stage === 3) { + Log.add('Under Qiang\u2019s cot: a lead token stamped with a black umbrella\u2026', 'sys'); + Game.addItem('mat_token', 1); + Game.setStage('q_main_2', 4); + } + }, + tombAction(which) { + const q = G.quests['q_main_5']; if (!q || q.done) return; + if (which === 'descent') { + if (q.stage === 1) { + Game.startBattle({ ids: ['e_sentinel'], scale: 1, meta: { kind: 'tomb', onWin: () => { + if ((G.flags.sentinelKills || 0) >= 2) Game.setStage('q_main_5', 2); + else Toast.show(`One guardian silenced. (${G.flags.sentinelKills || 0}/2)`); + } } }); + } else if (q.stage === 2) { + Game.startBattle({ ids: ['e_sentinel', 'e_assassin'], scale: 1.05, meta: { kind: 'boss', boss: 'door', onWin: () => { + Log.add('Heihu crashes down among the broken guardians. \u201cThe Patriarch sends his regards.\u201d', 'sys'); + Game.startBattle({ ids: ['e_boss_heihu'], scale: 1, meta: { kind: 'boss', boss: 'heihu', onWin: () => { + Game.setStage('q_main_5', 3); + Log.add('Beyond the fallen enforcer, the inner door grinds open\u2026', 'sys'); + } } }); + } } }); + } + } else if (which === 'claim') { + if (q.stage === 3) { + Game.addItem('mat_ledger', 1); + Game.unlockAch('keeper_truth'); + Game.completeQuest('q_main_5', { exp: 500, fame: 40 }); + G.flags.mainChapter = 5; + Game.startQuest('q_main_6'); + } + } + }, + finalAssault() { + const q = G.quests['q_main_6']; if (!q || q.done) return; + if (q.stage === 0) { + Game.startBattle({ ids: ['e_cultist', 'e_cultist', 'e_assassin'], scale: 1.05, meta: { kind: 'boss', boss: 'wave', onWin: () => { + Game.setStage('q_main_6', 1); + Log.add('Through the parting umbrellas strides an old man whose shadow falls wrong\u2026', 'bad'); + } } }); + } else if (q.stage === 1) { + Game.startBattle({ ids: ['e_boss_wu'], scale: 1, meta: { kind: 'boss', boss: 'wu', onWin: () => { + Game.completeQuest('q_main_6', { exp: 800, fame: 100 }); + G.flags.mainChapter = 6; + setTimeout(() => Game.showEnding(), 400); + } } }); + } + }, + + showEnding() { + const f = G.rep.fame, inf = G.rep.infamy; + const villain = !!G.flags.villainPath; + let code, title, body = ''; + if (villain) { + code = 'iron'; title = 'THE NEW IRON PATRIARCH'; + body = `Wu Zhaoshan falls \u2014 and the umbrellas do not close; they turn.\n\nThey kneel to YOU. The Heaven\u2019s Ledger burns in your brazier, name by name,\nand magistrates wake to find their masters changed.\nThe jianghu learns a new proverb: when it rains, carry the iron umbrella.\n\nHistory will argue forever whether you saved the rivers and lakes\nor merely became the next storm.`; + } else if (f - inf * 2 >= 160) { + code = 'legend'; title = 'GRAND MASTER OF THE AGE'; + body = `The tale of the ridge-clearer who toppled an empire\u2019s shadow travels\nfarther than any caravan. Sects name training halls after you;\nbeggars toast you in wines you never drank.\n\nYet you still rise early, sweep the courtyard yourself,\nand send silver home to Willow Creek.\n\nSome legends wear crowns. Yours wears road dust.`; + } else if (f - inf * 2 >= 70) { + code = 'hero'; title = 'HERO OF THE RIVERS AND LAKES'; + body = `The Iron Umbrella Society collapses; its ledgers hang in the Garrison hall\nlike a trophy made of paper thunder.\n\nYou stay long enough for the festivals, then slip away before they can\nbuild you a statue. There are always more roads than reputations,\nand yours still itches.`; + } else { + code = 'wander'; title = 'WANDERER OF THE OPEN ROAD'; + body = `The conspiracy dies quietly, the way most evils do \u2014 unfunded, unnamed.\n\nYou take no credit and seek none. In taverns from here to the desert,\nsomeone swears they once shared a fire with a stranger\nwho fought like ten rivers.\n\nThe stranger always leaves before dawn.`; + } + const partnerLine = G.partner ? `\n\nAt your side, ${NPCS[G.partner].name} watches the horizon and smiles:\nwherever you wander, that is home enough.` : ''; + const statsLine = `\n\n— Journey\u2019s End —\nDays on the road: ${G.time.day} · Level ${G.player.lvl}\nFame ${G.rep.fame} · Infamy ${G.rep.infamy} · Silver ${G.player.silver}\nFoes felled: ${G.flags.totalKills || 0}`; + UI.showEnding(title, body + partnerLine + statsLine); + try { localStorage.removeItem(SaveSys.KEY + 'auto'); } catch (e) {} + }, + + /* ---------------- misc ---------------- */ + dailyReset() { + G.daily = defaultDaily(); + if (typeof W2D !== 'undefined' && W2D.entsCache) W2D.entsCache = {}; // respawn world beasts + Game.rollBounties(); + // Hong appears randomly in the city each day + G.flags.hongVisible = G.time.day >= 4 && Util.chance(0.4); + }, + npcsVisibleHere() { + return (LOCS[G.loc].npcs || []).filter(id => { + const n = NPCS[id]; + if (n.hiddenNpc) return id === 'n_beggar_hong' ? !!G.flags.hongVisible && G.loc === 'l_city' : true; + return true; + }); + } +}; + +function UTIL_INT_NAME(id) { return INTERNALS[id] ? INTERNALS[id].name : id; } +function QSsafe(q) { return ((G.quests[q] || {}).done ? 99 : ((G.quests[q] || {}).stage ?? -1)); } diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..a09aacc --- /dev/null +++ b/js/main.js @@ -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'); +}); diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..c57d8b5 --- /dev/null +++ b/js/ui.js @@ -0,0 +1,1183 @@ +'use strict'; +/* ============================================================ + UI — screens, tabs, dialogue runner, battle renderer + ============================================================ */ + +const $ = (sel) => document.querySelector(sel); +function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstChild; } + +const Toast = { + show(msg, bad) { + let holder = document.getElementById('toasts'); + if (!holder) { holder = h('
'); document.getElementById('app').appendChild(holder); } + const t = h(`
${msg}
`); + holder.appendChild(t); + setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .4s'; }, 1800); + setTimeout(() => t.remove(), 2300); + } +}; + +/* ---------------- dialogue runner ---------------- */ +const Dialogue = { + el: null, + run(id) { + const D = DIALOGUES[id]; + if (!D) { console.warn('missing dialogue', id); return; } + this.cur = { id, node: 'start', D }; + Sfx.play('open'); + this.render(); + }, + resolve(v) { return typeof v === 'function' ? v(G) : v; }, + render() { + const node = this.cur.D.nodes[this.cur.node] || this.cur.D.nodes.start; + let opts = this.resolve(node.opts) || []; + opts = opts.filter(o => !o.cond || o.cond()); + const speaker = this.resolve(this.cur.D.name) || ''; + const text = this.resolve(node.t) || ''; + if (!this.el) { this.el = h('
'); document.getElementById('overlay').appendChild(this.el); } + const box = this.el.querySelector('.dlg-box'); + box.innerHTML = ` +
${this.cur.D.glyph || '话'}${Util.esc(speaker)}
+
${text}
+
`; + const holder = box.querySelector('.dlg-opts'); + for (const o of opts) { + const b = h(``); + b.onclick = () => { + Sfx.play('click'); + if (o.act) o.act(G); + if (o.shop) { this.close(); UI.openShop(o.shop); return; } + if (o.next && this.cur.D.nodes[o.next]) { this.cur.node = o.next; this.render(); return; } + this.close(); + }; + holder.appendChild(b); + } + }, + close() { if (this.el) { this.el.remove(); this.el = null; } } +}; +function speakerHue(D) { + // find matching npc for portrait color + for (const id of Object.keys(NPCS)) if (NPCS[id].dlg === D.id) return NPCS[id].hue; + return '#c9b28a'; +} + +/* ---------------- main UI object ---------------- */ +/* shared action definitions used by both the scene panel and the 2D world */ +const ACTION_DEFS = { + herb: ['🌿 Gather herbs', () => Game.gatherHerbs()], + mine: ['⛏️ Prospect for ore', () => Game.prospectMine()], + meditate: ['🧘 Meditate (2h)', () => Game.meditate()], + dummy: ['🥋 Train at posts', () => Game.trainDummy()], + secttasks: ['📋 Mission Hall', () => Game.sectTasks()], + board: ['📌 Bounty Board', null], // military variant resolved per-label at click time + gamble: ['🎲 Dice den', () => UI.openGamble()], + pickpocket: ['🖐️ Work the crowd', () => Game.pickpocket()], + camp: ['🏕️ Make camp', () => Game.campOut()], + fish: ['🎣 Fish off the pier', () => UI.openFishing()] +}; + +const UI = { + tab: 'scene', + battle: null, + + runAction(act) { + let fn = (ACTION_DEFS[act.id] || [])[1]; + if (!fn) return; + if (act.id === 'board') { const mil = /military/i.test(act.label || ''); const f = fn; fn = () => UI.openBoard(mil); } + fn(); + }, + + init() { + document.addEventListener('click', (e) => { + const el = e.target.closest('[data-act]'); + if (!el) return; + const [act, ...args] = el.dataset.act.split('|'); + UI.handle(act, args, el); + }); + }, + + handle(act, args, el) { + switch (act) { + case 'tab': UI.setTab(args[0]); break; + case 'travel': Game.travelTo(args[0]); break; + case 'talk': UI.talk(args[0]); break; + case 'shop': UI.openShop(args[0]); break; + case 'buy': Game.buyItem(args[0], args[1]); UI.refreshModalIf(); break; + case 'sell': Game.sellItem(args[1]); UI.refreshModalIf(); break; + case 'use': Game.useItem(+args[0]); UI.refresh(); break; + case 'equip': Game.equip(null, +args[0]); break; + case 'unequip': Game.unequip(args[0]); break; + case 'gift': UI.openGiftPicker(args[0]); break; + case 'give': Game.giveGift(args[0], args[1]); UI.closeModal(); break; + case 'spar': Game.spar(args[0]); break; + case 'learn-manual': Game.learnManual(args[0]); break; + case 'facil': UI.facility(args[0]); break; + case 'smith-up': Game.smithUpgrade(args[0]); break; + case 'brew': Game.brew(args[0]); break; + case 'board-take': Game.takeBounty(+args[0]); UI.openBoard(args[1] === 'mil'); break; + case 'board-hunt': Game.huntBounty(); break; + case 'tourney': Game.enterTournament(); break; + case 'party-toggle': Game.togglePartyMember(args[0]); break; + case 'attr-up': Game.spendAttrPoint(args[0]); UI.openLevelUp(); break; + case 'internal-set': G.player.internal = args[0]; Stats.recalc(G.player); UI.refresh(); break; + case 'light-set': G.player.light = args[0]; Stats.recalc(G.player); UI.refresh(); break; + case 'train-tech': Game.doTrainDummy(args[0]); break; + case 'gamble-bet': UI.gambleResult(Game.gambleBet(args[0], +args[1])); break; + case 'battle-move': if (UI.battle) UI.battle.beginCast(UI.battle.current, args[0], +args[1]); break; + case 'battle-strike': { const b = UI.battle; if (b && b.current) { const foe = b.nearestFoe(b.current, b.current.rng); if (foe) { b.basicAttack(b.current, foe); } else Toast.show('No enemy within reach.'); } break; } + case 'battle-internal': if (UI.battle) UI.battle.useInternalActive(); break; + case 'battle-cancel': if (UI.battle) UI.battle.cancelCast(); break; + case 'battle-end': if (UI.battle) UI.battle.endTurn(); break; + case 'sys-save': SaveSys.save(args[0]); UI.openSystem(); break; + case 'sys-load': if (SaveSys.load(args[0])) { UI.closeModal(); UI.afterLoad(); } else Toast.show('No such save.'); break; + case 'sys-del': SaveSys.del(args[0]); UI.openSystem(); break; + case 'sys-export': UI.exportSave(); break; + case 'sys-import': UI.importSave(); break; + case 'toggle-sfx': G.settings.sfx = !G.settings.sfx; UI.openSystem(); break; + case 'speed-set': G.settings.aiSpeed = +args[0]; UI.openSystem(); break; + case 'help': UI.openHelp(); break; + case 'about': UI.openAbout(); break; + case 'to-title': UI.confirmTitle(); break; + case 'new-game': UI.openCreator(); break; + case 'continue': UI.continueGame(); break; + case 'close-modal': UI.closeModal(); break; + } + }, + + /* ---------- screens ---------- */ + showScreen(id) { + for (const s of ['title', 'create', 'world']) { + $('#screen-' + s).hidden = (s !== id); + } + }, + + /* ---------- title ---------- */ + renderTitleMenu() { + const menu = $('#title-menu'); menu.innerHTML = ''; + const saves = SaveSys.list(); + const auto = saves.find(s => s.slot === 'auto'); + const bNew = h(''); + bNew.onclick = () => UI.openCreator(); + menu.appendChild(bNew); + if (auto && auto.info) { + const bC = h(``); + bC.onclick = () => UI.continueGame(); + menu.appendChild(bC); + } + const bSys = h(''); + bSys.onclick = () => UI.openSystem(true); + menu.appendChild(bSys); + const bHelp = h(''); + bHelp.onclick = () => UI.openHelp(); + menu.appendChild(bHelp); + const bAbout = h(''); + bAbout.onclick = () => UI.openAbout(); + menu.appendChild(bAbout); + }, + + continueGame() { + if (SaveSys.load('auto')) { UI.afterLoad(); } + else Toast.show('No journey to continue.'); + }, + afterLoad() { + Log.clearDom(); + UI.showScreen('world'); + UI.setTab('scene'); + UI.refresh(); + Toast.show(`Welcome back, ${Util.esc(G.player.name)}.`); + }, + + /* ---------- character creator ---------- */ + creator: null, + openCreator() { + UI.creator = { origin: ORIGIN_IDS[0], gender: 'm', name: '', pts: {}, left: 6, hard: false }; + UI.showScreen('create'); + UI.renderCreator(); + }, + renderCreator() { + const c = UI.creator; + const scr = $('#screen-create'); + const originsHtml = ORIGIN_IDS.map(id => { + const o = ORIGINS[id]; + const attrStr = Object.entries(o.attrs).map(([k, v]) => `${ATTRS.find(a => a[0] === k)[1].split(' ')[0]} +${v}`).join(', '); + return `
+ ${o.name}
${attrStr || 'no bonuses'} · ${o.silver} silver +

${o.desc}

`; + }).join(''); + const rows = ATTRS.map(([k, label]) => { + const base = 4 + (ORIGINS[c.origin].attrs[k] || 0); + const alloc = c.pts[k] || 0; + const total = Math.min(9, base + alloc); + const pips = Array.from({ length: 9 }, (_, i) => ``).join(''); + return `
+
${label}
${ATTRS.find(a=>a[0]===k)[2]}
+
${pips}
+
+ + ${total} + +
`; + }).join(''); + scr.innerHTML = `
+

Create Your Wanderer

+
+ + + + Points left: ${c.left} +
+
+
${originsHtml}
+
${rows}
+
+ + +
`; + // wire events + scr.querySelectorAll('.origin-card').forEach(card => card.onclick = () => { + c.origin = card.dataset.origin; c.pts = {}; c.left = 6; UI.renderCreator(); + }); + scr.querySelectorAll('[data-inc]').forEach(b => b.onclick = () => { const k = b.dataset.inc; const base = 4 + ORIGINS[c.origin].attrs[k]; if (c.left > 0 && base + (c.pts[k] || 0) < 9) { c.pts[k] = (c.pts[k] || 0) + 1; c.left--; UI.renderCreator(); } }); + scr.querySelectorAll('[data-dec]').forEach(b => b.onclick = () => { const k = b.dataset.dec; if ((c.pts[k] || 0) > 0) { c.pts[k]--; c.left++; UI.renderCreator(); } }); + scr.querySelectorAll('[data-gender]').forEach(b => b.onclick = () => { c.gender = b.dataset.gender; UI.renderCreator(); }); + const hardCb = $('#cc-hard'); + if (hardCb) hardCb.onchange = () => { c.hard = hardCb.checked; }; + $('#cc-name').oninput = (e) => { c.name = e.target.value; $('#cc-go').disabled = !c.name.trim(); }; + $('#cc-back').onclick = () => { UI.showScreen('title'); UI.renderTitleMenu(); }; + $('#cc-go').onclick = () => Game.newGame({ name: c.name.trim(), gender: c.gender, origin: c.origin, attrs: c.pts, hard: !!c.hard }); + }, + + /* ---------- topbar & tabs ---------- */ + refreshTop() { + const tb = $('#topbar'); if (!tb || !G.player) return; + const p = G.player; + tb.innerHTML = ` +
${Util.esc(p.name)} Lv.${p.lvl}
+
HP
${p.hp}/${p.d.maxHp}
+
Qi
${p.mp}/${p.d.maxMp}
+
XP
+
🪙 ${p.silver}
+
📜 ${G.rep.fame} fame${G.rep.infamy ? ` · ${G.rep.infamy} infamy` : ''}
+
+
📍 ${Util.esc(LOCS[G.loc].name)} · ${TimeSys.str()}
+ ${(G.flags.pendingAttr) ? '' : ''}`; + const pa = tb.querySelector('[data-act="attr-open-x"]'); + if (pa) pa.onclick = () => UI.openLevelUp(); + }, + + setTab(t) { this.tab = t; if (t !== 'scene' && typeof W2D !== 'undefined') W2D.stop(); this.refreshTabs(); this.renderTab(); }, + refreshTabs() { + const tabs = [['scene', 'Location'], ['map', 'World Map'], ['party', 'Character'], ['bag', 'Bag'], ['skills', 'Martial Arts'], ['journal', 'Journal'], ['codex', 'Codex'], ['system', 'System']]; + $('#tabs').innerHTML = tabs.map(([id, lbl]) => + ``).join(''); + $('#tabs').querySelectorAll('button').forEach(b => b.onclick = () => UI.setTab(b.dataset.tab)); + }, + + refresh() { + if (!G.player) return; + UI.refreshTop(); + UI.refreshTabs(); + UI.renderTab(); + }, + + renderTab() { + const v = $('#tabview'); + switch (this.tab) { + case 'scene': return UI.renderScene(v); + case 'map': return UI.renderMap(v); + case 'party': return UI.renderParty(v); + case 'bag': return UI.renderBag(v); + case 'skills': return UI.renderSkills(v); + case 'journal': return UI.renderJournal(v); + case 'codex': return UI.renderCodex(v); + case 'system': return UI.renderSystemInline(v); + } + }, + + /* ---------- scene ---------- */ + renderScene(v) { + const L = LOCS[G.loc]; + const npcCards = Game.npcsVisibleHere().map(id => { + const n = NPCS[id]; + const aff = G.aff[id] || 0; + const hearts = '❤'.repeat(Math.floor(aff / 20)); + const btns = [``]; + if (n.shop) btns.push(``); + if (n.teach && n.teach.length) btns.push(''); + if (n.spar) btns.push(``); + if (n.inn) {} // inn handled through talk + btns.push(``); + return `
+
${n.char}
+
+
${Util.esc(n.name)} ${hearts ? `${hearts}` : ''}
+
${Util.esc(n.title)}
+
${Util.esc(n.role)}
+
${btns.join('')}
+
`; + }).join(''); + + this._facilFns = []; + const facil = (L.actions || []).map((a, i) => { + const m = ACTION_DEFS[a.id]; if (!m) return ''; + this._facilFns[i] = () => UI.runAction(a); + return ``; + }).join(''); + + // story actions + let story = ''; + const q2 = G.quests['q_main_2']; + if (G.loc === 'l_mountain' && q2 && !q2.done && q2.stage >= 1 && q2.stage <= 2) story += ``; + if (G.loc === 'l_mountain' && q2 && !q2.done && q2.stage === 3) story += ``; + if (G.loc === 'l_mountain' && FVsafe('mainChapter') >= 4) story += ``; + if (G.loc === 'l_tomb') { + const q5 = G.quests['q_main_5']; + if (q5 && !q5.done) { + if (q5.stage === 1) story += ``; + if (q5.stage === 2) story += ``; + if (q5.stage === 3) story += ``; + } + } + if (G.loc === 'l_sect' && G.quests['q_main_6'] && !G.quests['q_main_6'].done) { + const st = G.quests['q_main_6'].stage; + story += ``; + } + + // travel row + const travels = TRAVEL.filter(e => e.includes(G.loc)).map(e => { + const other = e[0] === G.loc ? e[1] : e[0]; + const T = LOCS[other]; + if (T.hidden && !G.flags[T.hidden]) return ''; + const lockedNight = T.nightOnly && !TimeSys.isNight(); + return ``; + }).join(''); + + v.innerHTML = ` +
+ +
Click / tap map to walk there (tap NPC to talk) · WASD / Arrows also move · E interact · D-pad bottom-right for touch · glowing arches travel · touch beasts to fight
+
+

${Util.esc(L.name)} ${L.region}${L.danger ? `danger ${'★'.repeat(L.danger)}` : ''}${L.nightOnly ? 'night only' : ''}

+

${L.desc}

+ ${travels ? `
${travels}
` : ''} + ${story ? `
${story}
` : ''} +
${facil}
+
+
${npcCards}
`; + + v.querySelectorAll('[data-act-btn]').forEach(b => { + const [kind, id] = b.dataset.actBtn.split(':'); + b.onclick = () => { + if (kind === 'talk') UI.talk(id); + else if (kind === 'shop') UI.openShop(id); + else if (kind === 'spar') Game.spar(id); + else if (kind === 'gift') UI.openGiftPicker(id); + }; + }); + v.querySelectorAll('[data-travel]').forEach(b => b.onclick = () => Game.travelTo(b.dataset.travel)); + v.querySelectorAll('[data-facil]').forEach(b => { + const fn = (this._facilFns || [])[+b.dataset.facil]; + b.onclick = () => { if (fn) fn(); }; + }); + v.querySelectorAll('[data-story]').forEach(b => b.onclick = () => { + const k = b.dataset.story; + if (k === 'camp') Game.banditCampAction(); + else if (k === 'searchcamp') Game.banditCampAction(); + else if (k === 'tombgo') Game.travelTo('l_tomb'); + else if (k === 'sentinel') Game.tombAction('descent'); + else if (k === 'door') Game.tombAction('descent'); + else if (k === 'claim') Game.tombAction('claim'); + else if (k.startsWith('final')) Game.finalAssault(); + }); + const wcv = v.querySelector('#w2d'); + if (wcv && typeof W2D !== 'undefined') { + UI.runAfterPaint = () => W2D.enter(G.loc, wcv); + requestAnimationFrame(() => { + if (!UI.runAfterPaint) return; + try { UI.runAfterPaint(); } catch (e) { console.warn('[world] enter failed:', e); Toast.show('World failed to start — see console (F12)'); } + UI.runAfterPaint = null; + }); + } + }, + + /* ---------- map ---------- */ + renderMap(v) { + const cards = Object.keys(LOCS).map(id => { + const L = LOCS[id]; + const known = G.discovered[id]; + const edge = TRAVEL.find(e => e.includes(G.loc) && (e[0] === id || e[1] === id)); + const hrs = edge ? edge[2] : null; + const nightLock = L.nightOnly && !TimeSys.isNight(); + const hiddenLock = L.hidden && !G.flags[L.hidden]; + return `
+

${known ? Util.esc(L.name) : '???'}

+
${L.danger ? '★'.repeat(L.danger) : 'safe'}
+

${known ? L.desc : 'An unexplored corner of the jianghu.'}

+ ${G.loc === id ? 'you are here' : + edge ? `${hrs}h away ${nightLock ? '· opens at night' : hiddenLock ? '· path unknown' : '· click to travel'}` : 'no direct road'} +
`; + }).join(''); + v.innerHTML = `

The Jianghu

${cards}
`; + v.querySelectorAll('[data-mapgo]').forEach(c => c.onclick = () => Game.travelTo(c.dataset.mapgo)); + }, + + /* ---------- character ---------- */ + renderParty(v) { + const p = G.player; + const slotNames = { weapon: 'Weapon', head: 'Head', body: 'Body', feet: 'Feet', acc1: 'Accessory I', acc2: 'Accessory II' }; + const eqRows = Object.keys(slotNames).map(s => { + const id = p.equip[s]; + const it = id ? DATA.ITEMS[id] : null; + return `
${slotNames[s]} + ${it ? `${it.name} ${statStr(it)}` : '— empty —'} + ${it ? `` : ''}
`; + }).join(''); + const attrRows = ATTRS.map(([k, label]) => `${label.split(' ')[0]}${p.attrs[k]}${ATTRS.find(a=>a[0]===k)[2]}`).join(''); + const compRows = G.allies.map(id => { + const n = NPCS[id]; + const inParty = G.party.includes(id); + const hearts = '❤'.repeat(Math.floor((G.aff[id] || 0) / 20)); + return `
+
${n.char}
+
${Util.esc(n.name)} ${hearts ? `${hearts}` : ''}
${Util.esc(n.title)} · affection ${G.aff[id] || 0}/100
+
${inParty ? 'in battle party' : 'reserve'} +
`; + }).join(''); + v.innerHTML = ` +
+
+

${Util.esc(p.name)}

+

${ORIGINS[p.origin].name} · Level ${p.lvl} · EXP ${p.exp}/${Stats.expNext(p.lvl)}

+
+ ${attrRows}
+
+

Attack ${p.d.atk} · Defense ${p.d.def} · Speed ${p.d.spd} · Crit ${p.d.crit}% · Dodge ${p.d.dodge} · Move ${p.d.mv} · Range ${p.d.rng}

+

Active internal: ${INTERNALS[p.internal].name} Lv.${p.internalLv} (insight ${p.internalExp}/${p.internalLv * 55})
+ Lightness: ${LIGHTNESS[p.light].name}

+
+

Equipment

${eqRows}
+
+
+

Companions ${G.allies.length ? '' : '— none yet; the road provides —'}

+
${compRows}
`; + v.querySelectorAll('[data-un]').forEach(b => b.onclick = () => Game.unequip(b.dataset.un)); + v.querySelectorAll('[data-pt]').forEach(b => b.onclick = () => Game.togglePartyMember(b.dataset.pt)); + }, + + /* ---------- bag ---------- */ + renderBag(v) { + const p = G.player; + if (!p.inv.length) { v.innerHTML = '

Your pack is empty. The road provides… eventually.

'; return; } + const rows = p.inv.map((s, i) => { + const it = DATA.ITEMS[s.id]; + const acts = []; + if (['weapon', 'armor'].includes(it.type)) acts.push(``); + if (it.type === 'acc') { acts.push(``); } + if (it.type === 'use') acts.push(``); + if (it.type === 'manual') acts.push(``); + acts.push(``); + acts.push(``); + return `
${it.name}${s.q > 1 ? ` ×${s.q}` : ''} +
${it.desc}
${acts.join('')}
`; + }).join(''); + v.innerHTML = `

Bag (${p.inv.length} kinds)

+
${rows}
`; + v.querySelectorAll('[data-eq]').forEach(b => b.onclick = () => Game.equip(null, +b.dataset.eq)); + v.querySelectorAll('[data-use]').forEach(b => b.onclick = () => Game.useItem(+b.dataset.use)); + v.querySelectorAll('[data-gift]').forEach(b => b.onclick = () => UI.openGiftPicker(null, b.dataset.gift)); + v.querySelectorAll('[data-sell]').forEach(b => b.onclick = () => { Game.sellItem(b.dataset.sell); UI.renderTab(); }); + }, + + openGiftPicker(npcId, presetItem) { + const here = Game.npcsVisibleHere(); + if (!here.length) { Toast.show('No one is around.'); return; } + const gifts = G.player.inv.filter(s => { const t = DATA.ITEMS[s.id].type; return ['gift', 'use', 'mat'].includes(t) && !DATA.ITEMS[s.id].key; }).flatMap(s => Array.from({ length: Math.min(s.q, 5) }, () => s.id)); + if (!presetItem && !gifts.length) { Toast.show('Nothing suitable to give.'); return; } + const items = presetItem ? [presetItem] : [...new Set(gifts)]; + const npcOpts = (npcId ? [npcId] : here).filter(Boolean); + let html = '

Give what, to whom? People favor gifts that suit their nature.

'; + html += items.map(iid => { + const it = DATA.ITEMS[iid]; + return `
${it.name}
${it.desc}
+
${npcOpts.map(nid => ``).join('')}
`; + }).join(''); + UI.modal('Offer a Gift', html, [{ label: 'Done', fn: () => UI.closeModal() }]); + const m = $('#overlay .modal'); + m.querySelectorAll('[data-giveto]').forEach(b => b.onclick = () => { + const [nid, iid] = b.dataset.giveto.split('|'); + Game.giveGift(nid, iid); + UI.closeModal(); + }); + }, + + /* ---------- skills ---------- */ + renderSkills(v) { + const p = G.player; + const techBlocks = Object.keys(p.techs).map(tid => { + const T = TECHNIQUES[tid]; + const prof = p.techs[tid]; + const moves = T.moves.map((m, i) => { + const locked = prof < PROF_REQ[i]; + return `
+ ${locked ? '🔒 ' : ''}${m.n} + Qi ${m.mp}Pow ${m.p}%Rng ${m.r}${shapeLabel(m.sh)} + ${m.st ? `${m.st.k}` : ''}${m.cd ? `CD ${m.cd}` : ''}${m.hits ? `×${m.hits}` : ''}
`; + }).join(''); + return `
+
${T.name}${T.wt} · tier ${T.tier}
+

${T.desc}

+
Proficiency:${prof}/100${prof >= 100 ? ' ★mastered' : ''}
+
${moves}
+

Unlocks at proficiency: ${PROF_REQ.join(' / ')}. Use arts in battle or drill at training posts.

+
`; + }).join(''); + const internals = p.knownInternals.map(iid => { + const ia = INTERNALS[iid]; + const active = p.internal === iid; + return `
+
${ia.name}${active ? 'active' : ``}
+

${ia.desc}

+

Lv.${p.internalLv} — passive: +${(ia.hp0 || 0) + (ia.hpL || 0) * p.internalLv} HP, +${(ia.mp0 || 0) + (ia.mpL || 0) * p.internalLv} Qi${ia.atkL ? `, atk +${(ia.atk0 || 0) + ia.atkL * p.internalLv}` : ''}${ia.defL ? `, def +${(ia.def0 || 0) + ia.defL * p.internalLv}` : ''}

+

Active art: ${ia.active.n}${ia.active.desc}

+
`; + }).join(''); + const lights = p.knownLights.map(lid => { + const li = LIGHTNESS[lid]; const active = p.light === lid; + return `
+ ${li.name} move +${li.mv}, dodge +${li.dodge}${li.spd ? ', spd +' + li.spd : ''} + ${active ? 'active' : ``}
`; + }).join(''); + v.innerHTML = `

Martial Arts

${techBlocks} +

Internal Cultivation

${internals} +

Lightness Skills

${lights}`; + v.querySelectorAll('[data-int]').forEach(b => b.onclick = () => { G.player.internal = b.dataset.int; Stats.recalc(G.player); UI.refresh(); }); + v.querySelectorAll('[data-light]').forEach(b => b.onclick = () => { G.player.light = b.dataset.light; Stats.recalc(G.player); UI.refresh(); }); + }, + + /* ---------- journal ---------- */ + renderJournal(v) { + const entries = Object.keys(G.quests).map(qid => { + const q = G.quests[qid]; const def = QUESTS[qid]; + return `
+
${def.main ? '★ ' : ''}${def.name}${q.done ? 'complete' : 'stage ' + (q.stage + 1)}
+

${Game.questStageText(qid)}

`; + }).join(''); + const repRows = [['azure', 'Azure Cloud Sect'], ['temple', 'Pure Lotus Temple'], ['serpent', 'Serpent Veil Valley'], ['fist', 'Iron Fist Guild'], ['garrison', 'Imperial Garrison'], ['underground', 'Ghost Market']] + .map(([k, lbl]) => `${lbl}${G.rep[k] || 0}`).join(''); + v.innerHTML = `

Journal

+ ${entries || '

No quests yet. Talk to people; trouble finds the willing.

'} +
+
+

Reputation

${repRows}
+

Standing

Fame ${G.rep.fame} · Infamy ${G.rep.infamy}

+

Titles earned: ${G.flags.champion ? 'Tournament Champion' : ''}${G.flags.learnedHong ? 'Hong\u2019s Heir' : ''}${G.flags.azureMember ? 'Azure Cloud Disciple' : ''}${G.flags.fistMember ? 'Iron Fist Sibling' : ''}${G.flags.serpentMember ? 'Friend of the Valley' : ''}${G.flags.templeMember ? 'Lotus Gate Disciple' : ''}

+
+
+

Achievements (${Object.keys(G.flags.ach || {}).length}/${Object.keys(ACHIEVEMENTS).length})

+
+ ${Object.entries(ACHIEVEMENTS).map(([id, a]) => { + const got = (G.flags.ach || {})[id]; + return `
+
${got ? '🏆' : '🔒'} ${a.name}
${a.desc}
`; + }).join('')} +
`; + }, + + /* ---------- codex (bestiary) ---------- */ + renderCodex(v) { + const seen = G.flags.seen || {}; + const total = Object.keys(ENEMIES).length; + const found = Object.keys(ENEMIES).filter(id => seen[id]).length; + const cards = Object.keys(ENEMIES).map(id => { + const e = ENEMIES[id]; + const known = seen[id]; + if (!known) { + return `

???

${'★'.repeat(e.tier)}

Not yet encountered.

`; + } + const moves = (e.moves || []).map(m => m.n).join(' · ') || '—'; + const loot = (e.loot || []).map(l => DATA.ITEMS[l.id] ? DATA.ITEMS[l.id].name : l.id).join(', ') || 'none'; + return `
+

${e.glyph}${Util.esc(e.name)}

+
${'★'.repeat(e.tier)}${e.boss ? ' BOSS' : ''}
+

${BEAST_LORE[id] || ''}

+

HP ${e.hp} · ATK ${e.atk} · DEF ${e.def} · SPD ${e.spd} · Range ${e.rng || 1}

+

Arts: ${Util.esc(moves)}

+

Drops: ${Util.esc(loot)}

+
`; + }).join(''); + v.innerHTML = `

Monster Codex

+

Recorded from encounters on the road — ${found}/${total} catalogued.

+
${cards}
`; + }, + + /* ---------- system inline & modals ---------- */ + renderSystemInline(v) { + v.innerHTML = `

System

+
+ + + + +

Autosaves on rest, travel, quests and battles. Sound: ${G.settings.sfx ? 'on' : 'off'} (System → Save/Load).

+
`; + $('#sys-open-slots').onclick = () => UI.openSystem(); + $('#sys-help').onclick = () => UI.openHelp(); + $('#sys-about').onclick = () => UI.openAbout(); + $('#sys-title').onclick = () => UI.confirmTitle(); + }, + + confirmTitle() { + UI.modal('Return to Title?', '

Progress since the last autosave will remain on file.

', + [{ label: 'Stay', fn: () => UI.closeModal() }, + { label: 'Return', fn: () => { SaveSys.autosave(); UI.closeModal(); UI.showScreen('title'); UI.renderTitleMenu(); } }]); + }, + + openSystem(fromTitle) { + const slots = SaveSys.list().map(({ slot, info }) => { + const lbl = info ? `${Util.esc(info.name)} · Lv.${info.lvl} · Day ${info.day}` : '(empty)'; + return `
${slot.toUpperCase()} ${lbl}
+
+ ${fromTitle && info && slot !== 'auto' ? `` : ''} + ${!fromTitle ? `` : ''} + ${!fromTitle && slot !== 'auto' && info ? `` : ''} + ${info ? `` : ''} +
`; + }).join(''); + UI.modal('Save System', `

${fromTitle ? 'Load an existing journey:' : 'Three manual slots plus autosave.'}

${slots} +
+
+ + AI speed: + ${[600, 380, 200].map(s => ``).join('')}
+ `, + [{ label: 'Close', fn: () => UI.closeModal() }]); + const m = $('#overlay .modal'); + m.querySelectorAll('[data-saveslot]').forEach(b => b.onclick = () => { SaveSys.save(b.dataset.saveslot); Toast.show('Saved.'); UI.openSystem(); }); + m.querySelectorAll('[data-loadslot]').forEach(b => b.onclick = () => { if (SaveSys.load(b.dataset.loadslot)) { UI.closeModal(); if (!fromTitle) UI.afterLoad(); else { UI.showScreen('world'); UI.afterLoad(); } } }); + m.querySelectorAll('[data-delslot]').forEach(b => b.onclick = () => { SaveSys.del(b.dataset.delslot); UI.openSystem(fromTitle); }); + m.querySelector('[data-exp]').onclick = () => { const ta = m.querySelector('#impexp'); ta.style.display = 'block'; ta.value = SaveSys.exportStr(); ta.select(); try { document.execCommand('copy'); Toast.show('Copied.'); } catch (e) {} }; + m.querySelector('[data-imp]').onclick = () => { const ta = m.querySelector('#impexp'); ta.style.display = 'block'; ta.focus(); ta.placeholder = 'paste code then press Enter'; ta.onkeydown = (e) => { if (e.key === 'Enter') { if (SaveSys.importStr(ta.value)) { Toast.show('Imported!'); UI.closeModal(); UI.afterLoad(); } else Toast.show('Invalid code.', true); } }; }; + m.querySelector('[data-sfx]').onclick = () => { G.settings.sfx = !G.settings.sfx; UI.openSystem(fromTitle); }; + m.querySelectorAll('[data-speed]').forEach(b => b.onclick = () => { G.settings.aiSpeed = +b.dataset.speed; UI.openSystem(fromTitle); }); + }, + + exportSave() {}, + importSave() {}, + + openHelp() { + UI.modal('How to Play', ` +
+ Goal. Rise from village kid to legend of the rivers & lakes: finish the main story, master arts, befriend (or romance) companions.

+ Controls. In the Location tab you walk the world directly: WASD / Arrow keys to move, E (or Enter) to talk, gather, use spots and confirm portals; walk into glowing arches at map edges to travel; touch roaming beasts to start a battle. The buttons below the canvas do the same things.

+ Getting around. Location tab lists who\u2019s here and what you can do; World Map shows roads (hours = time cost). Time matters: shops keep hours, the Ghost Market opens only at night, tournaments run each 5th day.

+ Combat. Turn-based tactics on a grid. Each turn you may move (click a lit tile) and act once. Click a move button, then click a highlighted target tile; or click an adjacent enemy for a free-form Strike. Internal arts have powerful actives. Companions fight automatically.

+ Growth. Battles grant XP, silver, loot and technique proficiency (unlock stronger moves at 20/45/75, mastery bonus at 100). Level-ups give attribute points. Meditate to deepen your internal art; drill at training posts to polish techniques.

+ People. Gift often, spar friendly, finish favors \u2014 high affection unlocks secret teachings, recruitment and romance (affection ≥ 80). Reputation opens doors across six factions.

+ Money. Trade pelts & ore, brew pills, forge gear at Smith Wang, hunt bounties, gamble in dice dens, fish off the dock, or pick pockets (infamy has teeth).

+ Codex. Every foe you meet is catalogued under the Codex tab — stats, arts and drops. Achievements track milestones in the Journal.

+ Saving. Autosaves constantly; manual slots under System.
`, + [{ label: 'Understood', fn: () => UI.closeModal() }]); + }, + + openAbout() { + UI.modal('About', ` +

Jianghu Chronicles — Road of the Wandering Blade is an original, self-contained browser RPG inspired by the classic Chinese open-world martial-arts genre.

+ Every character, sect, technique, place and line of story here is an original creation written for this game. It is not affiliated with, nor derived from the assets or text of, any commercial title.

+ Built with vanilla HTML/CSS/JS + Canvas. No servers, no accounts \u2014 your journey lives in your browser.

`, + [{ label: 'Close', fn: () => UI.closeModal() }]); + }, + + /* ---------- generic modal ---------- */ + modal(title, bodyHTML, buttons) { + let bd = document.querySelector('#overlay .modal-backdrop'); + if (bd) bd.remove(); + bd = h(''); + $('#overlay').appendChild(bd); + const m = bd.querySelector('.modal'); + m.innerHTML = `

${title}

${bodyHTML}`; + const acts = m.querySelector('.modal-actions'); + for (const b of (buttons || [])) { + const btn = h(``); + btn.onclick = () => { Sfx.play('click'); b.fn && b.fn(); }; + acts.appendChild(btn); + } + bd.addEventListener('click', (e) => { if (e.target === bd) { /* click outside does nothing; explicit buttons only */ } }); + }, + closeModal() { + const bd = document.querySelector('#overlay .modal-backdrop'); + if (bd) bd.remove(); + }, + refreshModalIf() { if (document.querySelector('#overlay .modal-backdrop')) UI.renderTab(); }, + + toastSafe(msg) { Toast.show(Util.esc(msg)); }, + + /* ---------- shops ---------- */ + openShop(npcId) { + const n = NPCS[npcId]; if (!n.shop) return; + const stock = n.shop.stock.map(iid => { + const it = DATA.ITEMS[iid]; + return `${it.name}
${it.desc} + ${statStr(it)}${Game.buyPrice(it)}g + `; + }).join(''); + const sellable = G.player.inv.filter(s => !DATA.ITEMS[s.id].key).map(s => { + const it = DATA.ITEMS[s.id]; + return `${it.name} ${s.q > 1 ? `×${s.q}` : ''}${Game.sellPrice(it)}g + `; + }).join(''); + UI.modal(`${Util.esc(n.shop.name)} — ${Util.esc(n.name)}`, + `

Your purse: ${G.player.silver} silver

+

For Sale

+ ${stock}
ItemStatsPrice
+

Your Goods

+ ${sellable || ''}
nothing they want
`, + [{ label: 'Leave', fn: () => UI.closeModal() }]); + const m = $('#overlay .modal'); + m.querySelectorAll('[data-buy]').forEach(b => b.onclick = () => { const [nid, iid] = b.dataset.buy.split('|'); Game.buyItem(nid, iid); UI.openShop(npcId); }); + m.querySelectorAll('[data-sellitem]').forEach(b => b.onclick = () => { Game.sellItem(b.dataset.sellitem); UI.openShop(npcId); }); + }, + + openSmith() { + const owned = [...Object.values(G.player.equip).filter(Boolean), ...G.player.inv.map(s => s.id)]; + const ups = Object.keys(SMITH_UPGRADES).filter(k => owned.includes(k)).map(k => { + const up = SMITH_UPGRADES[k]; + const mats = Object.entries(up.mats).map(([m, q]) => { + const have = Game.countItem(m); + return `${DATA.ITEMS[m].name} ${have}/${q}`; + }).join(', '); + const affordSilver = G.player.silver >= up.silver; + return `
${DATA.ITEMS[k].name}${DATA.ITEMS[up.to].name} +
${up.silver}g · ${mats}
+
`; + }).join(''); + UI.modal('Ember Forge — Upgrades', + `

Wang spits into the coals. \u201cOre in, better steel out.\u201d

+
${ups || '

Bring equipment Wang can improve.

'}
`, + [{ label: 'Done', fn: () => UI.closeModal() }]); + const m = $('#overlay .modal'); + m.querySelectorAll('[data-smith]').forEach(b => b.onclick = () => Game.smithUpgrade(b.dataset.smith)); + }, + + openAlchemy() { + const recs = ALCHEMY_RECIPES.filter(r => !r.needFlag || G.flags[r.needFlag]).map(r => { + const mats = Object.entries(r.mats).map(([m, q]) => { + const have = Game.countItem(m); + return `${DATA.ITEMS[m].name} ${have}/${q}`; + }).join(', '); + return `
${r.name}
+ ${r.silver}g · ${mats}
+
`; + }).join(''); + UI.modal('Bai\u2019s Brewing Bench', + `

\u201cMeasure twice, drink once.\u201d

${recs}
`, + [{ label: 'Done', fn: () => UI.closeModal() }]); + const m = $('#overlay .modal'); + m.querySelectorAll('[data-brew]').forEach(b => b.onclick = () => Game.brew(b.dataset.brew)); + }, + + openBoard(military) { + if (!G.bounty.list.length || G.bounty.list.length < 3) Game.rollBounties(); + const rows = G.bounty.list.map((b, i) => { + const e = ENEMIES[b.id]; + const taken = G.bounty.taken === i; + return `
${e.name} ×${b.count} +
tier ${e.tier} · reward ${military ? Math.round(b.silver * 1.3) : b.silver}g, ${b.exp} XP, +${b.fame} fame
+ ${taken ? `` : + G.bounty.taken !== null ? 'contract held' : + ``}
`; + }).join(''); + + // job postings (side quests) + const jobs = Object.keys(Game.SIDE_JOBS).map(qid => { + const q = QUESTS[qid], job = Game.SIDE_JOBS[qid]; + const st = Game.sideState(qid); + let btn = ''; + if (st === 'offer') btn = ``; + else if (st === 'active') btn = `${job.prog()}`; + else if (st === 'ready') btn = ``; + else btn = 'done ✔'; + return `
${q.name} +
${job.give()}
${btn}
`; + }).join(''); + const jobsHtml = military ? '' : `

Job Postings

${jobs}
`; + + UI.modal(military ? 'Military Bounty Board' : 'Bulletin Board', + `

${military ? '\u201cThe Garrison pays for results.\u201d' : 'Notices, rewards, and one suspicious recipe.'}

${jobsHtml}

Bounties

${rows}
`, + [{ label: 'Leave', fn: () => UI.closeModal() }]); + const m = $('#overlay .modal'); + m.querySelectorAll('[data-take]').forEach(b => b.onclick = () => { Game.takeBounty(+b.dataset.take); UI.openBoard(military); }); + m.querySelectorAll('[data-job-accept]').forEach(b => b.onclick = () => { Game.sideStart(b.dataset.jobAccept); UI.openBoard(military); }); + m.querySelectorAll('[data-job-deliver]').forEach(b => b.onclick = () => { Game.sideDeliver(b.dataset.jobDeliver); UI.openBoard(military); }); + const hb = m.querySelector('[data-hunt]'); + if (hb) hb.onclick = () => { UI.closeModal(); Game.huntBounty(); }; + }, + + openGamble() { + const bets = [20, 50, 100, 200].filter(b => b <= G.player.silver); + UI.modal('Dice Den', `

Three dice. Big (11+) pays 1.9×, Small (10−) pays 1.9×, Triples pay 28×.

+

Purse: ${G.player.silver}g

+
${bets.map(b => `
${b}g + ${[['big', 'Big'], ['small', 'Small'], ['triple', 'Triple']].map(([k, l]) => ``).join(' ')}
`).join('')}
+
`, + [{ label: 'Walk away', fn: () => UI.closeModal() }]); + const m = $('#overlay .modal'); + m.querySelectorAll('[data-gamble]').forEach(b => b.onclick = () => { + const [k, amt] = b.dataset.gamble.split('|'); + const res = Game.gambleBet(k, +amt); + if (res) UI.gambleResult(res); + UI.openGamble(); + }); + }, + gambleResult(res) { + if (!res) return; + const out = $('#gamble-out'); + if (out) out.innerHTML = `

${res.dice.join(' · ')} = ${res.sum}${res.triple ? ' TRIPLE!' : ''} — ${res.win ? '+' + res.win + 'g!' : 'lost.'}

`; + }, + + openFishing() { + if (!Game.canFish()) { Toast.show('The fish have learned your face for today.'); return; } + let iv = null, pos = 0, dir = 1, zw = 20, zpos = 20, done = false; + const recast = () => { + if (!Game.canFish()) { out.textContent = 'That\u2019s enough fishing for one day. (10/10 casts)'; return; } + done = false; pos = 0; dir = 1; + zw = 15 + Util.rf() * 17; zpos = 8 + Util.rf() * (80 - zw); + zone.style.left = zpos + '%'; zone.style.width = zw + '%'; + clearInterval(iv); + const speed = 2.0 + Util.rf() * 1.9 + G.daily.fished * 0.12; // gets trickier + iv = setInterval(() => { + pos += dir * speed; + if (pos >= 100) { pos = 100; dir = -1; } + if (pos <= 0) { pos = 0; dir = 1; } + mark.style.left = `calc(${pos}% - 2px)`; + }, 18); + }; + const strike = () => { + if (done) return; + done = true; clearInterval(iv); + const hit = pos >= zpos && pos <= zpos + zw; + Game.fishResult(hit); + out.innerHTML = hit + ? `Caught! The line sings. (${G.daily.fished}/10)` + : `Missed — ripples only. (${G.daily.fished}/10)`; + Sfx.play(hit ? 'coin' : 'click'); + setTimeout(() => { if (document.body.contains(m)) recast(); else clearInterval(iv); }, 850); + }; + UI.modal('Fishing — Peach Blossom Dock', + `

Strike when the golden marker crosses the glinting water. Petals drift; patience wins.

+
+
+
+
+

 

`, + [{ label: '⚡ Strike!', primary: true, fn: strike }, + { label: 'Put the rod away (1h)', fn: () => { clearInterval(iv); TimeSys.advance(1); UI.closeModal(); } }]); + const m = $('#overlay .modal'); + const zone = m.querySelector('#fish-zone'); + const mark = m.querySelector('#fish-mark'); + const out = m.querySelector('#fish-out'); + recast(); + }, + + openTrainPick() { + const opts = Object.keys(G.player.techs).map(tid => { + const T = TECHNIQUES[tid]; + return `
${T.name}
proficiency ${G.player.techs[tid]}/100
+
`; + }).join(''); + UI.modal('Training Posts', `

Strike the posts until the forms dream themselves. (+3 proficiency)

${opts}
`, + [{ label: 'Enough', fn: () => UI.closeModal() }]); + const m = $('#overlay .modal'); + m.querySelectorAll('[data-train]').forEach(b => b.onclick = () => Game.doTrainDummy(b.dataset.train)); + }, + + openLevelUp() { + const p = G.player; + const left = G.flags.pendingAttr || 0; + if (!left) { UI.closeModal(); return; } + const rows = ATTRS.map(([k, label]) => `
+ ${label.split(' ')[0]} ${label.split(' ')[1]} — now ${p.attrs[k]} +
`).join(''); + UI.modal(`Level ${p.lvl}!`, `

Distribute ${left} attribute point${left > 1 ? 's' : ''}.

${rows}
`, + []); + const m = $('#overlay .modal'); + m.querySelectorAll('[data-attr]').forEach(b => b.onclick = () => { + Game.spendAttrPoint(b.dataset.attr); + if ((G.flags.pendingAttr || 0) > 0) UI.openLevelUp(); else UI.closeModal(); + UI.refreshTop(); + }); + }, + + showEnding(title, body) { + $('#screen-world').hidden = true; + const t = $('#screen-title'); + t.hidden = false; + t.innerHTML = `
+
${title}
+
${body.replace(/\n/g, '
')}
+
+
`; + $('#end-restart').onclick = () => location.reload(); + }, + + /* ================= BATTLE UI ================= */ + openBattle(battle) { + this.battle = battle; + G.inBattle = true; + if (typeof W2D !== 'undefined' && W2D.running) W2D.pause(true); + let wrap = document.getElementById('battle-ui'); + if (wrap) wrap.remove(); + wrap = h(`
+
+
Round 1
+
+ +
+
+
+
+
+
`); + $('#overlay').appendChild(wrap); + const cv = wrap.querySelector('#battle-canvas'); + + battle.on('refresh', () => UI.renderBattleBar()); + battle.on('banner', (txt) => { + const bn = wrap.querySelector('#bt-banner'); + if (txt) { bn.textContent = txt; bn.classList.add('show'); } + else bn.classList.remove('show'); + }); + battle.on('log', (m) => { + const lg = wrap.querySelector('#bt-log'); + if (lg) { const p = document.createElement('p'); p.innerHTML = m; lg.appendChild(p); lg.scrollTop = lg.scrollHeight; } + }); + battle.on('fx', (fx) => { + if (fx.unit && (fx.type === 'dmg' || fx.type === 'crit')) UI.floaters.push({ x: fx.unit.x, y: fx.unit.y, text: '-' + fx.v, color: fx.type === 'crit' ? '#ffd76a' : '#ff8a75', t: performance.now(), crit: fx.type === 'crit' }); + if (fx.unit && fx.type === 'heal') UI.floaters.push({ x: fx.unit.x, y: fx.unit.y, text: '+' + fx.v, color: '#9dbb74', t: performance.now() }); + if (fx.unit && fx.type === 'block') UI.floaters.push({ x: fx.unit.x, y: fx.unit.y, text: '⛨' + fx.v, color: '#8fb6d8', t: performance.now() }); + if (fx.unit && fx.type === 'miss') UI.floaters.push({ x: fx.unit.x, y: fx.unit.y, text: 'miss', color: '#a99e83', t: performance.now() }); + }); + + cv.addEventListener('mousemove', (e) => { + const r = cv.getBoundingClientRect(); + const scale = GRID_W * 64 / r.width; + UI.hoverTile = { x: Math.floor((e.clientX - r.left) * scale / 64), y: Math.floor((e.clientY - r.top) * scale / 64) }; + }); + cv.addEventListener('mouseleave', () => UI.hoverTile = null); + cv.addEventListener('click', (e) => { + const r = cv.getBoundingClientRect(); + const scale = GRID_W * 64 / r.width; + const x = Math.floor((e.clientX - r.left) * scale / 64); + const y = Math.floor((e.clientY - r.top) * scale / 64); + if (UI.battle) UI.battle.clickTile(x, y); + }); + + UI.floaters = []; + UI.hoverTile = null; + this._rafStart(); + battle.startRound(); + }, + + _rafStart() { + const step = () => { + if (!UI.battle) return; + UI.drawBattle(); + requestAnimationFrame(step); + }; + requestAnimationFrame(step); + }, + + closeBattle() { + this.battle = null; + const w = document.getElementById('battle-ui'); + if (w) w.remove(); + if (typeof W2D !== 'undefined') { + if (W2D.engaged && G._lastRes && G._lastRes.win) { W2D.engaged.dead = true; } + W2D.engaged = null; + W2D.pause(false); + } + UI.refresh(); + }, + + renderBattleBar() { + const b = this.battle; if (!b) return; + const u = b.current; + $('#bt-round').textContent = `Round ${b.round}`; + $('#bt-turn').innerHTML = u ? `${Util.esc(u.name)}` : ''; + $('#bt-phase').textContent = b.over ? 'battle over' : (u && u.ai === 'player' ? (b.selectedMove ? 'choose target' : 'your turn') : 'resolving…'); + const bar = $('#bt-bar'); + if (!u || b.over || u.ai !== 'player') { bar.innerHTML = ''; } + else { + let mvBtns = ''; + for (const tid of u.techs) { + const T = TECHNIQUES[tid]; if (!T) continue; + T.moves.forEach((m, mi) => { + const prof = G.player.techs[tid] || 0; + const locked = prof < (PROF_REQ[mi] || 0); + if (locked) return; + const chk = b.canUseMove(u, tid, mi); + const sel = b.selectedMove && b.selectedMove.tech === tid && b.selectedMove.mi === mi; + mvBtns += ``; + }); + } + bar.innerHTML = ` + + ${mvBtns} + ${u.internal ? `` : ''} + + + HP ${u.hp}/${u.hpMax} · Qi ${u.mp}/${u.mpMax}${u.moveLeft && !u.moved ? ' · steps left ' + u.moveLeft : ''}`; + bar.querySelector('[data-strike]').onclick = () => { const foe = b.nearestFoe(u, u.rng); if (foe) b.basicAttack(u, foe); else Toast.show('No enemy within reach.'); }; + bar.querySelectorAll('[data-mv]').forEach(btn => btn.onclick = () => { const [tid, mi] = btn.dataset.mv.split('|'); b.beginCast(u, tid, +mi); }); + const ib = bar.querySelector('[data-intact]'); if (ib) ib.onclick = () => b.useInternalActive(); + const cb = bar.querySelector('[data-cancel]'); if (cb) cb.onclick = () => b.cancelCast(); + bar.querySelector('[data-endturn]').onclick = () => b.endTurn(); + } + // unit strip + const strip = $('#bt-strip'); + strip.innerHTML = b.units.filter(x => x.alive).map(x => ` +
+ ${x.isPlayer ? '★' : ''}${Util.esc(x.name)} +
+ ${x.hp}/${x.hpMax} + ${x.sts.map(s => `${STATUS_GLYPH[s.k] || s.k[0]}${s.k === 'shield' ? s.v : ''}`).join('')} +
`).join(''); + }, + + drawBattle() { + const b = this.battle; if (!b) return; + const cv = document.getElementById('battle-canvas'); + if (!cv) return; + const ctx = cv.getContext('2d'); + const C = 64; + // bg + ctx.fillStyle = '#12100c'; ctx.fillRect(0, 0, cv.width, cv.height); + for (let x = 0; x < GRID_W; x++) for (let y = 0; y < GRID_H; y++) { + ctx.fillStyle = (x + y) % 2 ? '#161310' : '#191512'; + ctx.fillRect(x * C, y * C, C, C); + ctx.strokeStyle = 'rgba(216,179,106,.06)'; + ctx.strokeRect(x * C + .5, y * C + .5, C - 1, C - 1); + } + // side shading + ctx.fillStyle = 'rgba(111,168,143,.05)'; ctx.fillRect(0, 0, 2 * C, cv.height); + ctx.fillStyle = 'rgba(192,90,78,.06)'; ctx.fillRect(cv.width - 2 * C, 0, 2 * C, cv.height); + + // highlights + const u = b.current; + if (u && !b.over && u.ai === 'player') { + if (b.selectedMove) { + for (const t of b.targetTilesFor(u, b.selectedMove.def)) { + ctx.fillStyle = 'rgba(208,101,87,.28)'; + ctx.fillRect(t.x * C + 2, t.y * C + 2, C - 4, C - 4); + ctx.strokeStyle = 'rgba(208,101,87,.8)'; + ctx.strokeRect(t.x * C + 2.5, t.y * C + 2.5, C - 5, C - 5); + } + } else if (!u.moved) { + for (const key of Object.keys(b.reach || {})) { + const [x, y] = key.split(',').map(Number); + ctx.fillStyle = 'rgba(216,179,106,.14)'; + ctx.fillRect(x * C + 3, y * C + 3, C - 6, C - 6); + } + } + } + // hover + if (UI.hoverTile) { + ctx.strokeStyle = 'rgba(240,205,133,.7)'; + ctx.strokeRect(UI.hoverTile.x * C + 1.5, UI.hoverTile.y * C + 1.5, C - 3, C - 3); + } + + // units + for (const unit of b.units) { + if (!unit.alive) continue; + const px = unit.x * C, py = unit.y * C; + // token + const grad = ctx.createLinearGradient(px, py, px, py + C); + grad.addColorStop(0, shade(unit.color, 18)); + grad.addColorStop(1, unit.color); + ctx.fillStyle = grad; + rr(ctx, px + 7, py + 6, C - 14, C - 20, 9); + ctx.fill(); + ctx.strokeStyle = unit.side === 'ally' ? 'rgba(140,190,160,.85)' : 'rgba(210,110,95,.85)'; + ctx.lineWidth = 2; + rr(ctx, px + 7, py + 6, C - 14, C - 20, 9); + ctx.stroke(); + // glyph + ctx.fillStyle = '#141210'; + ctx.font = 'bold 26px "Noto Serif SC", serif'; + ctx.textAlign = 'center'; + ctx.fillText(unit.glyph, px + C / 2, py + C / 2 + 2); + // hp bar + const hw = C - 16; + ctx.fillStyle = '#000a'; rr(ctx, px + 8, py + C - 13, hw, 6, 3); ctx.fill(); + ctx.fillStyle = unit.side === 'ally' ? '#84b06a' : '#c05a4e'; + rr(ctx, px + 8, py + C - 13, Math.max(2, hw * unit.hp / unit.hpMax), 6, 3); ctx.fill(); + // mp pip + ctx.fillStyle = '#6f93b8'; + rr(ctx, px + 8, py + C - 5, Math.max(1, hw * unit.mp / Math.max(1, unit.mpMax)), 2.5, 1); ctx.fill(); + // statuses + let sx = px + 6; + for (const s of unit.sts.slice(0, 5)) { + ctx.fillStyle = STATUS_COLOR(s.k); + ctx.beginPath(); ctx.arc(sx + 3, py + 10, 3, 0, 7); ctx.fill(); + sx += 8; + } + // active ring + if (unit === b.current && !b.over) { + ctx.strokeStyle = '#f0cd85'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(px + C / 2, py + C / 2, C / 2 - 3 + Math.sin(performance.now() / 250) * 1.5, 0, 7); ctx.stroke(); + } + } + // floaters + const now = performance.now(); + UI.floaters = UI.floaters.filter(f => now - f.t < 900); + for (const f of UI.floaters) { + const age = (now - f.t) / 900; + ctx.globalAlpha = 1 - age; + ctx.fillStyle = f.color; + ctx.font = (f.crit ? 'bold 20px' : 'bold 15px') + ' Georgia'; + ctx.textAlign = 'center'; + ctx.fillText(f.text, f.x * C + C / 2, f.y * C + 14 - age * 26); + ctx.globalAlpha = 1; + } + } +}; + +const STATUS_GLYPH = { poison: '毒', bleed: '血', burn: '炎', stun: '晕', slow: '缓', atkup: '武', defup: '盾', spdup: '风', regen: '春', shield: '罩' }; +function STATUS_COLOR(k) { + return { poison: '#7ba05b', bleed: '#c05a4e', burn: '#d08a3e', stun: '#d8b36a', slow: '#8fa0b8', atkup: '#c05a4e', defup: '#8fb6d8', spdup: '#9dbb74', regen: '#9dbb74', shield: '#8fb6d8' }[k] || '#fff'; +} +function shapeLabel(sh) { + return { foe: 'single', line: 'line', cross: 'cross', area: 'blast', self: 'self', ally: 'ally' }[sh] || sh; +} +function statStr(it) { + const parts = []; + if (it.atk) parts.push('atk+' + it.atk); + if (it.def) parts.push('def+' + it.def); + if (it.spd) parts.push('spd+' + it.spd); + if (it.crit) parts.push('crit+' + it.crit); + if (it.hp) parts.push('hp+' + it.hp); + if (it.mp) parts.push('qi+' + it.mp); + if (it.rng && it.type === 'weapon') parts.push('rng' + it.rng); + if (it.bonus) for (const [k, v] of Object.entries(it.bonus)) parts.push(k + '+' + v); + return parts.join(' '); +} +function rr(ctx, x, y, w, hgt, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + hgt, r); + ctx.arcTo(x + w, y + hgt, x, y + hgt, r); + ctx.arcTo(x, y + hgt, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} +function shade(hex, amt) { + const n = parseInt(hex.slice(1), 16); + const r = Util.clamp(((n >> 16) & 255) + amt, 0, 255), g = Util.clamp(((n >> 8) & 255) + amt, 0, 255), b = Util.clamp((n & 255) + amt, 0, 255); + return `rgb(${r},${g},${b})`; +} + +/* helpers used across files */ +function Game_hasNewJournal() { return Object.values(G.quests || {}).some(q => !q.done); } diff --git a/js/world2d.js b/js/world2d.js new file mode 100644 index 0000000..e2e218c --- /dev/null +++ b/js/world2d.js @@ -0,0 +1,1106 @@ +/* ========================================================================= + world2d.js — graphical overworld layer + Top-down tile world: walk with WASD/arrows, talk to NPCs (E), gather, + fight roamers, use portals to travel. Reuses all game data & systems. + ========================================================================= */ + +'use strict'; + +const W2D = { + T: 48, // tile size (px) + VW: 960, VH: 540, // logical viewport + cv: null, ctx: null, + cur: null, // current loc id + grid: null, // {w,h,g,exits,spots,npcSpots,roamSpots,style} + ground: null, // prerendered offscreen canvas + px: 0, py: 0, dir: 2, // player pixel pos (feet), dir:0N 1E 2S 3W + moving: false, anim: 0, + trail: [], // follower trail + cam: { x: 0, y: 0 }, + keys: Object.create(null), + running: false, paused: false, + ents: [], // live npcs/roamers/spots for current map + engaged: null, // roamer that triggered current battle + posCache: {}, // locId -> {x,y} persist position per visit + _bound: false, _raf: 0, _last: 0, + banner: 0, +}; + +/* ---------------- deterministic randomness ---------------- */ +W2D.hashSeed = function (s) { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } + return h >>> 0; +}; +W2D.rngFrom = function (seed) { + let s = seed >>> 0; + return function () { + s ^= s << 13; s >>>= 0; s ^= s >> 17; s ^= s << 5; s >>>= 0; + return s / 4294967296; + }; +}; + +/* ---------------- tile palette ---------------- */ +// 0 grass 1 path 2 water 3 tree 4 wall 5 door 6 flower 7 rock 8 wood +// 9 sand 10 stone 11 crop 12 lantern 13 grave 14 stair 15 banner +// 16 stall 17 torch 18 shrine 19 boat 20 well 21 bridge 22 petal +const BLOCKED = new Set([2, 3, 4, 7, 12, 13, 15, 16, 17, 18, 19, 20]); +const SOLID_BORDER = { village: 3, town: 3, city: 4, dock: 2, temple: 3, sect: 7, market: 4, tomb: 4, bamboo: 3, mountain: 7, valley: 3 }; + +W2D.styleFor = function (locId) { + if (locId === 'l_dock') return 'dock'; + if (locId === 'l_temple') return 'temple'; + if (locId === 'l_sect') return 'sect'; + if (locId === 'l_market') return 'market'; + if (locId === 'l_tomb') return 'tomb'; + if (locId === 'l_bamboo') return 'bamboo'; + if (locId === 'l_mountain') return 'mountain'; + if (locId === 'l_valley') return 'valley'; + if (locId === 'l_city' || locId === 'l_town') return 'city'; + return 'village'; +}; + +/* ---------------- map generation (pure, testable) ---------------- */ +W2D.genGrid = function (locId) { + const L = LOCS[locId]; + const style = W2D.styleFor(locId); + const rnd = W2D.rngFrom(W2D.hashSeed(locId + '|' + style)); + const w = 44, h = 32; + const g = []; + for (let y = 0; y < h; y++) { g.push(new Array(w).fill(0)); } + + const set = (x, y, t) => { if (x >= 0 && y >= 0 && x < w && y < h) g[y][x] = t; }; + const get = (x, y) => (x >= 0 && y >= 0 && x < w && y < h) ? g[y][x] : 7; + + /* border ring */ + const bb = SOLID_BORDER[style]; + for (let x = 0; x < w; x++) { set(x, 0, bb); set(x, h - 1, bb); } + for (let y = 0; y < h; y++) { set(0, y, bb); set(w - 1, y, bb); } + + /* style bases */ + if (style === 'dock') { + for (let y = 2; y < h - 2; y++) for (let x = w - 14; x < w - 1; x++) set(x, y, 2); + for (let y = 0; y < h; y++) for (let x = w - 14; x < w; x++) if (g[y][x] === 2 && rnd() < 0.06) set(x, y, 19); + } else if (style === 'temple') { + for (let y = 4; y < h - 4; y++) for (let x = 6; x < w - 6; x++) if (rnd() < 0.85) set(x, y, 10); + } else if (style === 'sect') { + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (g[y][x] === 0 && rnd() < 0.5) set(x, y, 9); + } else if (style === 'market' || style === 'tomb') { + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (g[y][x] === 0) set(x, y, 10); + } else if (style === 'valley') { + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (g[y][x] === 0 && rnd() < 0.22) set(x, y, 22); + } + + /* exits: one per travel edge, distributed N,E,S,W */ + const edges = TRAVEL.filter(e => e.includes(locId)); + const dirs = []; + const order = [[0, 'N'], [1, 'E'], [2, 'S'], [3, 'W']]; + edges.forEach((e, i) => { + const [dx, tag] = order[i % 4]; + const other = e[0] === locId ? e[1] : e[0]; + let ex, ey; + if (tag === 'N') { ex = 6 + Math.floor(rnd() * (w - 12)); ey = 0; } + else if (tag === 'S') { ex = 6 + Math.floor(rnd() * (w - 12)); ey = h - 1; } + else if (tag === 'E') { ex = w - 1; ey = 4 + Math.floor(rnd() * (h - 8)); } + else { ex = 0; ey = 4 + Math.floor(rnd() * (h - 8)); } + // carve 3-tile gap + for (let k = -1; k <= 1; k++) { + if (tag === 'N') set(ex + k, 0, 1), set(ex + k, 1, 1); + if (tag === 'S') set(ex + k, h - 1, 1), set(ex + k, h - 2, 1); + if (tag === 'E') set(w - 1, ey + k, 1), set(w - 2, ey + k, 1); + if (tag === 'W') set(0, ey + k, 1), set(1, ey + k, 1); + } + dirs.push({ to: other, x: ex, y: ey, tag, hours: e[2] }); + }); + + /* paths: connect each exit to center without running along the border */ + const cx = w >> 1, cy = h >> 1; + dirs.forEach(d => { + let x = d.x, y = d.y; + if (d.tag === 'N' || d.tag === 'S') { + const midY = d.tag === 'N' ? 3 : h - 4; + while (y !== midY) { set(x, y, 1); y += Math.sign(midY - y); } + while (x !== cx) { set(x, y, 1); x += Math.sign(cx - x); } + while (y !== cy) { set(x, y, 1); y += Math.sign(cy - y); } + } else { + const midX = d.tag === 'W' ? 3 : w - 4; + while (x !== midX) { set(x, y, 1); x += Math.sign(midX - x); } + while (y !== cy) { set(x, y, 1); y += Math.sign(cy - y); } + while (x !== cx) { set(x, y, 1); x += Math.sign(cx - x); } + } + set(x, y, 1); + }); + + /* decorations per style */ + const freeTile = () => { + for (let tries = 0; tries < 200; tries++) { + const x = 2 + Math.floor(rnd() * (w - 4)), y = 2 + Math.floor(rnd() * (h - 4)); + if (g[y][x] === 0 || g[y][x] === 9 || g[y][x] === 10) return { x, y }; + } + return null; + }; + const scatter = (t, n, r = 0.0) => { for (let i = 0; i < n; i++) { const p = freeTile(); if (p && rnd() > r) set(p.x, p.y, t); } }; + + if (style === 'village') { + scatter(3, 26); scatter(6, 14); + // farm plots + houses + for (let i = 0; i < 5; i++) { const p = freeTile(); if (p) { for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 3; dx++) set(p.x + dx, p.y + dy, 11); } } + for (let i = 0; i < 6; i++) { const p = freeTile(); if (p) { set(p.x, p.y, 4); set(p.x, p.y + 1, 4); set(p.x + 1, p.y, 4); set(p.x + 1, p.y + 1, 5); } } + const wp = freeTile(); if (wp) set(wp.x, wp.y, 20); + } else if (style === 'city' && locId === 'l_city') { + for (let i = 0; i < 10; i++) { const p = freeTile(); if (p) { set(p.x, p.y, 4); set(p.x + 1, p.y, 4); set(p.x, p.y + 1, 4); set(p.x + 1, p.y + 1, 4); set(p.x, p.y + 2, 5); } } + scatter(12, 10); scatter(3, 8); + } else if (style === 'city') { // town + for (let i = 0; i < 7; i++) { const p = freeTile(); if (p) { set(p.x, p.y, 4); set(p.x + 1, p.y, 4); set(p.x, p.y + 1, 4); set(p.x + 1, p.y + 1, 5); } } + scatter(16, 6); scatter(3, 10); + } else if (style === 'dock') { + for (let i = 0; i < 2; i++) { const p = { x: w - 12 + i * 5, y: 8 + Math.floor(rnd() * 10) }; for (let dx = 0; dx < 6; dx++) set(p.x + dx, p.y, 8); } + for (let i = 0; i < 3; i++) { const p = freeTile(); if (p) set(p.x, p.y, 17); } + scatter(6, 8); + } else if (style === 'temple') { + for (let i = 0; i < 4; i++) { const p = freeTile(); if (p) set(p.x, p.y, 18); } + scatter(3, 10, 0.5); scatter(12, 8); + for (let i = 0; i < 6; i++) { const p = freeTile(); if (p) set(p.x, p.y, 17); } + } else if (style === 'sect') { + for (let y = 6; y < h - 6; y += 3) for (let x = 4; x < w - 4; x++) if (rnd() < 0.5 && g[y][x] !== 1) set(x, y, 14); + scatter(7, 16); scatter(15, 6); scatter(17, 4); + } else if (style === 'market') { + scatter(16, 12); scatter(12, 16); + } else if (style === 'tomb') { + scatter(13, 18); scatter(17, 10); scatter(7, 8); + } else if (style === 'bamboo') { + for (let y = 2; y < h - 2; y++) for (let x = 2; x < w - 2; x++) if (rnd() < 0.16 && g[y][x] === 0) set(x, y, 3); + scatter(6, 10); + } else if (style === 'mountain') { + scatter(7, 34); scatter(3, 12, 0.4); scatter(18, 4); + } else if (style === 'valley') { + scatter(6, 30); scatter(3, 18); scatter(2, 0); + } + + /* keep spawn plaza clear */ + const clearR = 3; + for (let y = cy - clearR; y <= cy + clearR; y++) for (let x = cx - clearR; x <= cx + clearR; x++) { + if (x > 0 && y > 0 && x < w - 1 && y < h - 1) { if (BLOCKED.has(g[y][x])) g[y][x] = 1; } + } + + /* collect free tiles for entity placement */ + const free = []; + for (let y = 2; y < h - 2; y++) for (let x = 2; x < w - 2; x++) { + if (!BLOCKED.has(g[y][x]) && g[y][x] !== 2) free.push({ x, y }); + } + const pickSpaced = (n, minD) => { + const out = []; + let guard = 0; + while (out.length < n && guard++ < 800) { + const p = free[Math.floor(rnd() * free.length)]; + if (Math.abs(p.x - cx) + Math.abs(p.y - cy) < 4) continue; + if (out.every(q => Math.abs(q.x - p.x) + Math.abs(q.y - p.y) >= minD)) out.push({ x: p.x, y: p.y }); + } + return out; + }; + + const npcSpots = pickSpaced((L.npcs || []).length, 6); + const spotDefs = (L.actions || []).filter(a => ['herb', 'mine', 'meditate', 'dummy', 'secttasks', 'board', 'gamble', 'pickpocket', 'camp', 'fish'].includes(a.id)); + const spotPts = pickSpaced(spotDefs.length, 7); + const spots = spotDefs.map((a, i) => ({ act: a, x: spotPts[i] ? spotPts[i].x : cx, y: spotPts[i] ? spotPts[i].y : cy })); + + const pools = L.pools || {}; + const poolIds = pools.mid || pools.low || pools.high || []; + const danger = L.danger || 0; + const nRoam = Math.min(4, danger + 1); + const roamSpots = (danger > 0 && poolIds.length) ? pickSpaced(nRoam, 8).map(p => ({ id: poolIds[Math.floor(rnd() * poolIds.length)], x: p.x, y: p.y })) : []; + + return { w, h, g, exits: dirs, spots, npcSpots, roamSpots, style, cx, cy }; +}; + +/* ---------------- prerender ground ---------------- */ +W2D.prerender = function (grid, locId) { + const cv = document.createElement('canvas'); + cv.width = grid.w * W2D.T; cv.height = grid.h * W2D.T; + const c = cv.getContext('2d'); + const rnd = W2D.rngFrom(W2D.hashSeed('gfx|' + locId)); + const T = W2D.T; + for (let y = 0; y < grid.h; y++) for (let x = 0; x < grid.w; x++) { + const t = grid.g[y][x], X = x * T, Y = y * T; + // base + if (t === 2 || t === 19) c.fillStyle = '#1c3a52'; + else if (t === 10) c.fillStyle = '#3c4048'; + else if (t === 9) c.fillStyle = '#5c5648'; + else if (t === 8 || t === 21) c.fillStyle = '#6e5236'; + else c.fillStyle = (x + y) % 2 ? '#31472f' : '#354c33'; + c.fillRect(X, Y, T, T); + // texture noise + if (t !== 2 && t !== 19 && rnd() < 0.3) { c.fillStyle = 'rgba(255,255,255,.03)'; c.fillRect(X + rnd() * T, Y + rnd() * T, 3, 3); } + // per-type details + if (t === 1) { c.fillStyle = '#5d5142'; c.fillRect(X, Y, T, T); c.fillStyle = 'rgba(0,0,0,.12)'; c.fillRect(X, Y + T - 4, T, 4); } + else if (t === 2 || t === 19) { c.strokeStyle = 'rgba(160,200,230,.18)'; c.beginPath(); c.moveTo(X + 4, Y + 10 + rnd() * 12); c.lineTo(X + T - 4, Y + 10 + rnd() * 12); c.stroke(); if (t === 19) { c.fillStyle = '#4a3524'; c.fillRect(X + 6, Y + 6, T - 12, T - 12); c.fillStyle = '#7a6248'; c.fillRect(X + 6, Y + 2, T - 12, 8); } } + else if (t === 3) { c.fillStyle = '#274a33'; c.beginPath(); c.arc(X + T / 2, Y + T / 2 - 3, T / 2 - 3, 0, 7); c.fill(); c.fillStyle = 'rgba(255,255,255,.08)'; c.beginPath(); c.arc(X + T / 2 - 4, Y + T / 2 - 7, 5, 0, 7); c.fill(); c.fillStyle = '#4a3a28'; c.fillRect(X + T / 2 - 2, Y + T / 2 + 4, 4, 9); } + else if (t === 4) { c.fillStyle = '#6e6152'; c.fillRect(X, Y, T, T); c.strokeStyle = 'rgba(0,0,0,.25)'; c.strokeRect(X + .5, Y + .5, T - 1, T - 1); c.fillStyle = 'rgba(255,255,255,.06)'; c.fillRect(X, Y, T, 5); } + else if (t === 5) { c.fillStyle = '#3c4048'; c.fillRect(X, Y, T, T); c.fillStyle = '#7a3b30'; c.fillRect(X + 4, Y + 4, T - 8, T - 8); c.fillStyle = '#d8b36a'; c.fillRect(X + T / 2 - 2, Y + T / 2, 4, 4); } + else if (t === 6 || t === 22) { const col = t === 6 ? (rnd() < .5 ? '#c76f8a' : '#d8b36a') : '#d98aa8'; c.fillStyle = col; for (let k = 0; k < 3; k++) { c.beginPath(); c.arc(X + 6 + rnd() * (T - 12), Y + 6 + rnd() * (T - 12), 2.2, 0, 7); c.fill(); } } + else if (t === 7) { c.fillStyle = '#4d565f'; c.beginPath(); c.moveTo(X + 4, Y + T - 6); c.lineTo(X + T / 2, Y + 5); c.lineTo(X + T - 4, Y + T - 6); c.closePath(); c.fill(); c.fillStyle = 'rgba(255,255,255,.1)'; c.beginPath(); c.moveTo(X + T / 2, Y + 5); c.lineTo(X + T - 4, Y + T - 6); c.lineTo(X + T / 2, Y + T - 6); c.closePath(); c.fill(); } + else if (t === 8 || t === 21) { c.strokeStyle = 'rgba(0,0,0,.3)'; c.strokeRect(X + .5, Y + .5, T - 1, T - 1); c.fillStyle = 'rgba(0,0,0,.15)'; c.fillRect(X, Y + T / 2, T, 2); } + else if (t === 10) { c.strokeStyle = 'rgba(0,0,0,.18)'; c.strokeRect(X + .5, Y + .5, T - 1, T - 1); } + else if (t === 11) { c.fillStyle = '#2e3b22'; c.fillRect(X + 2, Y + 6, T - 4, T - 10); c.strokeStyle = '#7c8a3e'; for (let k = 0; k < 4; k++) { c.beginPath(); c.moveTo(X + 5 + k * 6, Y + T - 6); c.lineTo(X + 5 + k * 6, Y + 8); c.stroke(); } } + else if (t === 12) { c.fillStyle = '#3c4048'; c.fillRect(X, Y, T, T); c.fillStyle = '#5a4632'; c.fillRect(X + T / 2 - 2, Y + 8, 4, T - 10); c.fillStyle = '#e8b54d'; c.fillRect(X + T / 2 - 5, Y + 3, 10, 8); } + else if (t === 13) { c.fillStyle = '#3c4048'; c.fillRect(X, Y, T, T); c.fillStyle = '#565c64'; c.fillRect(X + 7, Y + 10, T - 14, T - 16); c.fillRect(X + T / 2 - 2, Y + 5, 4, 5); } + else if (t === 14) { c.fillStyle = '#4a505a'; c.fillRect(X, Y, T, T); c.fillStyle = 'rgba(255,255,255,.08)'; c.fillRect(X, Y, T, 3); } + else if (t === 15) { c.fillStyle = '#3c4048'; c.fillRect(X, Y, T, T); c.fillStyle = '#5a4632'; c.fillRect(X + T / 2 - 2, Y + 6, 4, T - 8); c.fillStyle = '#a03a30'; c.fillRect(X + T / 2 + 1, Y + 6, 9, 14); } + else if (t === 16) { c.fillStyle = '#3c4048'; c.fillRect(X, Y, T, T); c.fillStyle = '#6e5236'; c.fillRect(X + 3, Y + 8, T - 6, T - 12); c.fillStyle = '#a03a30'; c.fillRect(X + 2, Y + 4, T - 4, 6); } + else if (t === 17) { c.fillStyle = '#3c4048'; c.fillRect(X, Y, T, T); c.fillStyle = '#5a4632'; c.fillRect(X + T / 2 - 2, Y + 10, 4, T - 12); c.fillStyle = '#e8843d'; c.beginPath(); c.arc(X + T / 2, Y + 8, 4, 0, 7); c.fill(); } + else if (t === 18) { c.fillStyle = '#3c4048'; c.fillRect(X, Y, T, T); c.fillStyle = '#6a7078'; c.fillRect(X + 6, Y + 8, T - 12, T - 12); c.fillStyle = '#2a2e34'; c.fillRect(X + T / 2 - 3, Y + 12, 6, 8); } + else if (t === 20) { c.fillStyle = '#31472f'; c.fillRect(X, Y, T, T); c.fillStyle = '#565c64'; c.beginPath(); c.arc(X + T / 2, Y + T / 2, T / 2 - 4, 0, 7); c.fill(); c.fillStyle = '#1c3a52'; c.beginPath(); c.arc(X + T / 2, Y + T / 2, T / 2 - 8, 0, 7); c.fill(); } + } + return cv; +}; + +/* ---------------- rich ground renderer (v2, overrides above) ---------------- */ +W2D.prerender = function (grid, locId) { + const T = W2D.T; + const cv = document.createElement('canvas'); + cv.width = grid.w * T; cv.height = grid.h * T; + const c = cv.getContext('2d'); + const rnd = W2D.rngFrom(W2D.hashSeed('gfx2|' + locId)); + const water = []; + const at = (x, y) => (x >= 0 && y >= 0 && x < grid.w && y < grid.h) ? grid.g[y][x] : 7; + + for (let ty = 0; ty < grid.h; ty++) for (let tx = 0; tx < grid.w; tx++) { + const t = at(tx, ty), X = tx * T, Y = ty * T, R = rnd; + /* base fill per family */ + if (t === 2 || t === 19 || t === 21) { // water family + const deep = (at(tx, ty - 1) === 2 && at(tx - 1, ty) === 2); + const grd = c.createLinearGradient(X, Y, X, Y + T); + grd.addColorStop(0, deep ? '#16334a' : '#1d4258'); + grd.addColorStop(1, '#122a3e'); + c.fillStyle = grd; c.fillRect(X, Y, T, T); + water.push([X, Y]); + // shore foam where land touches + const foam = (dx, dy) => !BLOCKED.has(at(tx + dx, ty + dy)) && at(tx + dx, ty + dy) !== 2; + c.fillStyle = 'rgba(190,220,235,.35)'; + if (foam(0, -1)) c.fillRect(X, Y, T, 4); + if (foam(-1, 0)) c.fillRect(X, Y, 4, T); + if (foam(0, 1)) c.fillRect(X, Y + T - 4, T, 4); + if (foam(1, 0)) c.fillRect(X + T - 4, Y, 4, T); + } else if (t === 10 || t === 13 || t === 14 || t === 17 || t === 18) { // stone floor family + c.fillStyle = '#454b54'; c.fillRect(X, Y, T, T); + c.strokeStyle = 'rgba(0,0,0,.22)'; + c.strokeRect(X + .5 + (tx % 2) * 6, Y + .5, T - 1, T - 1); + c.fillStyle = 'rgba(255,255,255,.05)'; c.fillRect(X, Y, T, 3); + if (R() < .18) { c.strokeStyle = 'rgba(0,0,0,.18)'; c.beginPath(); c.moveTo(X + 6, Y + 8); c.lineTo(X + T - 10, Y + T - 12); c.stroke(); } // crack + if (R() < .12) { c.fillStyle = 'rgba(110,150,90,.25)'; c.beginPath(); c.arc(X + T * .7, Y + T * .75, 5, 0, 7); c.fill(); } // moss + } else if (t === 8) { // pier planks + c.fillStyle = '#7a5c3c'; c.fillRect(X, Y, T, T); + for (let i = 0; i < 4; i++) { c.fillStyle = i % 2 ? '#6f5236' : '#81624a'; c.fillRect(X, Y + i * (T / 4), T, T / 4 - 2); } + c.fillStyle = 'rgba(0,0,0,.35)'; + [[6, 6], [T - 8, 6], [6, T - 8], [T - 8, T - 8]].forEach(([px2, py2]) => c.fillRect(X + px2, Y + py2, 3, 3)); + } else if (t === 11) { // crop rows + c.fillStyle = '#4a4030'; c.fillRect(X, Y, T, T); + for (let r2 = 0; r2 < 3; r2++) { c.fillStyle = '#3a3325'; c.fillRect(X, Y + 8 + r2 * 13, T, 4); } + c.strokeStyle = '#86a03e'; c.lineWidth = 2; + for (let k2 = 0; k2 < 5; k2++) { const sx = X + 6 + k2 * 9 + R() * 3; c.beginPath(); c.moveTo(sx, Y + T - 6); c.quadraticCurveTo(sx + 3, Y + T - 20, sx + (R() * 8 - 4), Y + 12); c.stroke(); } + c.lineWidth = 1; + } else { // land default: grass/sand/dirt base + const sand = (t === 9) || grid.style === 'sect'; + const g1 = sand ? '#6a6350' : '#39543a', g2 = sand ? '#5f5947' : '#31492f'; + c.fillStyle = ((tx + ty) % 2) ? g1 : g2; c.fillRect(X, Y, T, T); + if (!sand && R() < .55) { // grass blade tufts + c.strokeStyle = 'rgba(140,180,100,.30)'; + for (let k2 = 0; k2 < 4; k2++) { const bx = X + 4 + R() * (T - 8), by = Y + 6 + R() * (T - 10); c.beginPath(); c.moveTo(bx, by); c.lineTo(bx + (R() * 4 - 2), by - 5); c.stroke(); } + } + if (!sand && t === 0 && R() < .06) { c.fillStyle = R() < .5 ? '#c76f8a' : '#d8b36a'; c.beginPath(); c.arc(X + 8 + R() * (T - 16), Y + 8 + R() * (T - 16), 2.6, 0, 7); c.fill(); } + if (t === 1) { // dirt path overlay with soft edge + c.fillStyle = '#63553f'; c.fillRect(X, Y, T, T); + c.fillStyle = 'rgba(0,0,0,.10)'; c.fillRect(X, Y + T - 5, T, 5); + c.fillStyle = 'rgba(255,255,255,.05)'; c.fillRect(X, Y, T, 3); + if (R() < .5) { c.fillStyle = '#57493a'; const px2 = X + 6 + R() * (T - 12), py2 = Y + 6 + R() * (T - 12); c.beginPath(); c.arc(px2, py2, 2.4, 0, 7); c.fill(); } + if (R() < .3) { c.fillStyle = '#6d5f49'; c.fillRect(X + 4 + R() * (T - 20), Y + 10 + R() * (T - 20), 8, 3); } + } + if (t === 22) { // valley petals ground + c.fillStyle = 'rgba(217,138,168,.5)'; + for (let k2 = 0; k2 < 4; k2++) { c.beginPath(); c.ellipse(X + 6 + R() * (T - 12), Y + 6 + R() * (T - 12), 3.4, 2, R() * 3, 0, 7); c.fill(); } + } + } + + /* props */ + if (t === 3) { // tree + c.fillStyle = 'rgba(0,0,0,.28)'; c.beginPath(); c.ellipse(X + T / 2, Y + T - 6, T * .38, T * .14, 0, 0, 7); c.fill(); + c.fillStyle = '#5a4632'; c.fillRect(X + T / 2 - 5, Y + T * .45, 10, T * .5); + c.fillStyle = 'rgba(0,0,0,.2)'; c.fillRect(X + T / 2 + 1, Y + T * .45, 4, T * .5); + const cy2 = Y + T * .34, cx2 = X + T / 2; + const lobes = [[-14, -4, 15], [13, -2, 14], [0, -14, 16], [-6, 8, 12], [8, 8, 11]]; + lobes.forEach(([ox, oy, rr]) => { c.fillStyle = '#2c5236'; c.beginPath(); c.arc(cx2 + ox, cy2 + oy + 3, rr, 0, 7); c.fill(); }); + lobes.forEach(([ox, oy, rr]) => { c.fillStyle = '#3b6a44'; c.beginPath(); c.arc(cx2 + ox - 2, cy2 + oy - 1, rr * .82, 0, 7); c.fill(); }); + c.fillStyle = 'rgba(200,230,160,.22)'; c.beginPath(); c.arc(cx2 - 8, cy2 - 10, 6, 0, 7); c.fill(); + } else if (t === 4) { // plaster+timber wall + c.fillStyle = '#8b7d68'; c.fillRect(X, Y, T, T); + c.strokeStyle = '#4a3c2c'; c.lineWidth = 3; + c.strokeRect(X + 1.5, Y + 1.5, T - 3, T - 3); + c.beginPath(); c.moveTo(X + 3, Y + 3); c.lineTo(X + T - 3, Y + T - 3); c.moveTo(X + T - 3, Y + 3); c.lineTo(X + 3, Y + T - 3); c.stroke(); + c.lineWidth = 1; + c.fillStyle = 'rgba(0,0,0,.18)'; c.fillRect(X, Y + T - 5, T, 5); + } else if (t === 5) { // door w/ paper windows + c.fillStyle = '#4a3c2c'; c.fillRect(X, Y, T, T); + c.fillStyle = '#7a3b30'; c.fillRect(X + 5, Y + 5, T - 10, T - 10); + c.fillStyle = '#e8d9a0'; c.fillRect(X + 9, Y + 9, T - 18, T * .35); // paper window + c.strokeStyle = '#5a2c24'; c.strokeRect(X + 9.5, Y + 9.5, T - 19, T * .35); + c.fillStyle = '#d8b36a'; c.fillRect(X + T / 2 - 2, Y + T * .62, 4, 4); + } else if (t === 6) { // flowers cluster + ['#c76f8a', '#d8b36a', '#e8e3cf'].forEach((col, i) => { + const fx = X + 10 + i * 12 + R() * 4, fy = Y + 12 + (i % 2) * 16; + c.strokeStyle = '#4c7040'; c.beginPath(); c.moveTo(fx, fy + 7); c.lineTo(fx, fy); c.stroke(); + c.fillStyle = col; for (let p2 = 0; p2 < 4; p2++) { c.beginPath(); c.arc(fx + Math.cos(p2 * 1.57) * 3, fy + Math.sin(p2 * 1.57) * 3, 2.4, 0, 7); c.fill(); } + c.fillStyle = '#f0e6a0'; c.beginPath(); c.arc(fx, fy, 1.6, 0, 7); c.fill(); + }); + } else if (t === 7) { // boulder + c.fillStyle = 'rgba(0,0,0,.28)'; c.beginPath(); c.ellipse(X + T / 2, Y + T - 7, T * .36, T * .13, 0, 0, 7); c.fill(); + const grd = c.createLinearGradient(X, Y + 6, X, Y + T); + grd.addColorStop(0, '#6a747e'); grd.addColorStop(1, '#464f58'); + c.fillStyle = grd; + c.beginPath(); c.moveTo(X + 6, Y + T - 8); c.lineTo(X + T * .32, Y + 8); c.lineTo(X + T * .62, Y + 12); c.lineTo(X + T - 6, Y + T - 8); c.closePath(); c.fill(); + c.fillStyle = 'rgba(255,255,255,.16)'; c.beginPath(); c.moveTo(X + T * .32, Y + 8); c.lineTo(X + T * .62, Y + 12); c.lineTo(X + T * .5, Y + T - 10); c.closePath(); c.fill(); + } else if (t === 12) { // lantern post + c.fillStyle = 'rgba(0,0,0,.25)'; c.beginPath(); c.ellipse(X + T / 2, Y + T - 5, 8, 3, 0, 0, 7); c.fill(); + c.fillStyle = '#4a3c2c'; c.fillRect(X + T / 2 - 3, Y + 10, 6, T - 14); + c.fillStyle = '#8b2f26'; c.fillRect(X + T / 2 - 9, Y + 2, 18, 16); + c.fillStyle = '#f0c05a'; c.fillRect(X + T / 2 - 6, Y + 5, 12, 10); + c.strokeStyle = '#4a3c2c'; c.strokeRect(X + T / 2 - 9.5, Y + 1.5, 19, 17); + } else if (t === 13) { // grave mound + c.fillStyle = '#565c64'; c.beginPath(); c.arc(X + T / 2, Y + T * .58, T * .3, Math.PI, 0); c.fill(); + c.fillStyle = '#6a7078'; c.fillRect(X + T / 2 - 5, Y + 8, 10, 14); + c.fillStyle = '#2a2e34'; c.fillRect(X + T / 2 - 3, Y + 11, 6, 8); + } else if (t === 14) { // stairs + c.fillStyle = '#4d545e'; c.fillRect(X, Y, T, T); + for (let s2 = 0; s2 < 4; s2++) { c.fillStyle = s2 % 2 ? '#565e69' : '#454c56'; c.fillRect(X, Y + s2 * (T / 4), T, T / 4 - 2); c.fillStyle = 'rgba(255,255,255,.07)'; c.fillRect(X, Y + s2 * (T / 4), T, 2); } + } else if (t === 15) { // banner + c.fillStyle = '#4a3c2c'; c.fillRect(X + T / 2 - 3, Y + 4, 6, T - 8); + const wave = Math.sin((tx + ty)) * 3; + c.fillStyle = '#a03a30'; c.beginPath(); + c.moveTo(X + T / 2 + 3, Y + 6); c.quadraticCurveTo(X + T / 2 + 16, Y + 10 + wave, X + T / 2 + 15, Y + 26 + wave); + c.lineTo(X + T / 2 + 3, Y + 24); c.closePath(); c.fill(); + c.fillStyle = '#e8d9a0'; c.font = '9px serif'; c.textAlign = 'center'; c.fillText('武', X + T / 2 + 9, Y + 20 + wave); + } else if (t === 16) { // market stall + c.fillStyle = '#6e5236'; c.fillRect(X + 3, Y + T * .4, T - 6, T * .52); + c.fillStyle = '#a03a30'; c.fillRect(X, Y + 6, T, 10); + c.fillStyle = '#c94f42'; c.fillRect(X, Y + 6, T, 4); + c.fillStyle = '#e8dcc2'; c.fillRect(X + 8, Y + T * .55, T - 16, 6); + } else if (t === 17) { // torch + c.fillStyle = '#4a3c2c'; c.fillRect(X + T / 2 - 3, Y + 14, 6, T - 18); + c.fillStyle = '#e8843d'; c.beginPath(); c.arc(X + T / 2, Y + 12, 6, 0, 7); c.fill(); + c.fillStyle = '#f5c96a'; c.beginPath(); c.arc(X + T / 2, Y + 11, 3, 0, 7); c.fill(); + } else if (t === 18) { // shrine + c.fillStyle = '#6a7078'; c.fillRect(X + 8, Y + 10, T - 16, T - 14); + c.fillStyle = '#7d848d'; c.fillRect(X + 5, Y + 6, T - 10, 7); + c.fillStyle = '#22262c'; c.fillRect(X + T / 2 - 4, Y + 18, 8, 10); + c.fillStyle = 'rgba(240,200,120,.5)'; c.fillRect(X + T / 2 - 3, Y + 20, 2, 2); c.fillRect(X + T / 2 + 1, Y + 20, 2, 2); + } else if (t === 19) { // boat + c.fillStyle = '#5a4632'; c.beginPath(); c.ellipse(X + T / 2, Y + T / 2, T * .42, T * .26, 0, 0, 7); c.fill(); + c.fillStyle = '#7a6248'; c.fillRect(X + T * .2, Y + T * .38, T * .6, 5); + c.fillStyle = '#8b6a4a'; c.fillRect(X + T / 2 - 2, Y + 4, 4, 14); + } else if (t === 20) { // well + c.fillStyle = '#565c64'; c.beginPath(); c.arc(X + T / 2, Y + T / 2, T * .36, 0, 7); c.fill(); + c.fillStyle = '#141f2c'; c.beginPath(); c.arc(X + T / 2, Y + T / 2, T * .24, 0, 7); c.fill(); + c.fillStyle = '#4a3c2c'; c.fillRect(X + 6, Y + 4, 5, 12); c.fillRect(X + T - 11, Y + 4, 5, 12); + c.fillStyle = '#7a5236'; c.fillRect(X + 4, Y + 2, T - 8, 5); + } + } + + /* ambient occlusion under blocking tiles (depth cue) */ + for (let ty = 1; ty < grid.h; ty++) for (let tx = 0; tx < grid.w; tx++) { + if (BLOCKED.has(at(tx, ty - 1)) && !BLOCKED.has(at(tx, ty))) { + const grd = c.createLinearGradient(0, ty * T, 0, ty * T + 10); + grd.addColorStop(0, 'rgba(0,0,0,.30)'); grd.addColorStop(1, 'rgba(0,0,0,0)'); + c.fillStyle = grd; c.fillRect(tx * T, ty * T, T, 10); + } + } + + cv._water = water; + return cv; +}; + +/* ---------------- enter / lifecycle ---------------- */ +W2D.enter = function (locId, canvas) { + if (typeof document === 'undefined') return; + W2D.stop(); + W2D.cur = locId; + W2D.grid = W2D.genGrid(locId); + try { + W2D.ground = W2D.prerender(W2D.grid, locId); + W2D.gfxFallback = false; + } catch (e) { + console.warn('[world] rich renderer failed, using flat fallback:', e); + W2D.gfxFallback = true; + W2D.ground = W2D._flatGround(W2D.grid); + } + try { W2D.initParts(); } catch (_) { W2D.partMode = null; } + W2D.cv = canvas; W2D.ctx = canvas.getContext('2d'); + const gr = W2D.grid; + + // entities (cached per location so dead roamers stay dead between UI refreshes) + if (!W2D.entsCache) W2D.entsCache = {}; + if (W2D.entsCache[locId]) { + W2D.ents = W2D.entsCache[locId]; + } else { + W2D.ents = []; + gr.npcSpots.forEach((p, i) => { const id = LOCS[locId].npcs[i]; if (id) W2D.ents.push({ kind: 'npc', id, x: p.x * W2D.T + 16, y: p.y * W2D.T + 16 }); }); + gr.spots.forEach(s => W2D.ents.push({ kind: 'spot', act: s.act, x: s.x * W2D.T + 16, y: s.y * W2D.T + 16 })); + gr.roamSpots.forEach((p, i) => W2D.ents.push({ kind: 'roam', id: p.id, gx: p.x, gy: p.y, x: p.x * W2D.T + 16, y: p.y * W2D.T + 16, cool: 400 + i * 130, dead: false, immune: 0 })); + W2D.entsCache[locId] = W2D.ents; + } + + // spawn: cached pos or center + const cache = W2D.posCache[locId]; + if (cache && !W2D.blockedAt(cache.x, cache.y)) { W2D.px = cache.x; W2D.py = cache.y; } + else { W2D.px = gr.cx * W2D.T + 16; W2D.py = gr.cy * W2D.T + 16; } + W2D.cam.x = W2D.px - W2D.VW / 2; W2D.cam.y = W2D.py - W2D.VH / 2; + W2D.target = null; W2D.pending = null; + W2D.vk = { up: false, down: false, left: false, right: false }; + W2D.banner = 2600; + W2D.bindKeys(); + canvas.setAttribute('tabindex', '0'); + W2D.bindPointer(canvas); + try { canvas.focus({ preventScroll: true }); } catch (_) {} + const host = canvas.parentElement; + if (host) W2D.buildDpad(host); + W2D.paused = false; + W2D.running = true; + W2D._last = performance.now(); + cancelAnimationFrame(W2D._raf); + const tick = (t) => { + if (!W2D.running) return; + W2D._raf = requestAnimationFrame(tick); + const dt = Math.min(50, t - W2D._last); W2D._last = t; + if (!W2D.paused && !W2D.uiBlocked()) { + try { W2D.update(dt, t); } catch (e) { W2D._uwarned = W2D._uwarned || {}; if (!W2D._uwarned[e.message]) { W2D._uwarned[e.message] = 1; console.warn('[world update]', e); } } + } + W2D.draw(t); + }; + W2D._raf = requestAnimationFrame(tick); +}; + +W2D.stop = function () { W2D.running = false; cancelAnimationFrame(W2D._raf); }; +W2D.pause = function (v) { W2D.paused = v; }; + +W2D.blockedAt = function (px, py) { + const gr = W2D.grid; if (!gr) return true; + const x = Math.floor(px / W2D.T), y = Math.floor(py / W2D.T); + if (x < 0 || y < 0 || x >= gr.w || y >= gr.h) return true; + return BLOCKED.has(gr.g[y][x]); +}; + +W2D.bindKeys = function () { + if (W2D._bound) return; + W2D._bound = true; + const KEY_MAP = { + w: 'w', z: 'w', arrowup: 'w', + s: 's', arrowdown: 's', + a: 'a', q: 'a', arrowleft: 'a', + d: 'd', arrowright: 'd', + e: 'e', enter: 'e', ' ': 'e' + }; + const CODE_MAP = { KeyW: 'w', ArrowUp: 'w', KeyS: 's', ArrowDown: 's', KeyA: 'a', ArrowLeft: 'a', KeyD: 'd', ArrowRight: 'd', KeyE: 'e', Enter: 'e', Space: 'e' }; + // trust e.key first; fall back to physical e.code only when e.key is unusable + const norm = (e) => { + const k = (e.key || '').toLowerCase(); + return KEY_MAP[k] || (e.code ? CODE_MAP[e.code] : null) || k; + }; + document.addEventListener('keydown', (e) => { + const k = norm(e); + W2D.keys[k] = true; + if (!W2D.running || W2D.paused || G.inBattle) return; + if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright', ' '].includes(k)) e.preventDefault(); + if (k === 'e') W2D.interact(); + }, true); + document.addEventListener('keyup', (e) => { W2D.keys[norm(e)] = false; }, true); + // release everything when the tab loses focus + window.addEventListener('blur', () => { W2D.keys = Object.create(null); W2D.vk = { up: false, down: false, left: false, right: false }; }); +}; + +/* ---------------- pointer / touch controls ---------------- */ +W2D.bindPointer = function (cv) { + const toWorld = (ev) => { + const r = cv.getBoundingClientRect(); + return { + x: (ev.clientX - r.left) * (W2D.VW / r.width) + W2D.cam.x, + y: (ev.clientY - r.top) * (W2D.VH / r.height) + W2D.cam.y + }; + }; + cv.style.cursor = 'crosshair'; + cv.addEventListener('pointerdown', (ev) => { + ev.preventDefault(); + try { cv.focus({ preventScroll: true }); } catch (_) {} + const p = toWorld(ev); + // clicked on/near an interactable? walk there then trigger it + let hit = null, hd = 1e9; + const scan = (ent, kind, label) => { + const d = Math.hypot(ent.x - p.x, ent.y - p.y); + if (d < 34 && d < hd) { hd = d; hit = { ent, kind }; } + }; + for (const e of W2D.ents) { + if (e.kind === 'roam' && e.dead) continue; + if (e.kind === 'npc') scan(e, 'npc'); + else if (e.kind === 'spot') scan(e, 'spot'); + } + for (const ex of W2D.grid.exits) { + const L2 = LOCS[ex.to]; + if (L2.hidden && !G.flags[L2.hidden]) continue; + if (L2.nightOnly && !TimeSys.isNight()) continue; + scan({ kind: 'exit', to: ex.to, x: ex.x * W2D.T + 16, y: ex.y * W2D.T + 16 }, 'exit'); + } + if (!hit) for (const e of W2D.ents) { if (e.kind === 'roam' && !e.dead) { const d = Math.hypot(e.x - p.x, e.y - p.y); if (d < 34) hit = { ent: e, kind: 'roam' }; } } + W2D.target = { x: p.x, y: p.y }; + W2D.pending = hit; // {kind, ent} + }); +}; + +W2D.buildDpad = function (host) { + if (host.querySelector('.dpad')) return; + const mk = (cls, label, act) => ``; + const el = h(`
+ ${mk('up', 'up', '▲')}${mk('left', 'left', '◀')}${mk('e', 'E', 'E')}${mk('right', 'right', '▶')}${mk('down', 'down', '▼')} +
`); + host.appendChild(el); + el.querySelectorAll('[data-dk]').forEach(b => { + const k = b.dataset.dk; + const set = (v) => (ev) => { ev.preventDefault(); if (k === 'E') { if (v) W2D.interact(); } else W2D.vk[k] = v; b.classList.toggle('on', v); }; + b.addEventListener('pointerdown', set(true)); + b.addEventListener('pointerup', set(false)); + b.addEventListener('pointerleave', set(false)); + b.addEventListener('pointercancel', set(false)); + }); +}; + +W2D.uiBlocked = function () { + if (G.inBattle) return true; + if (typeof document === 'undefined') return true; + const els = document.querySelectorAll('.modal-backdrop,.dlg-backdrop'); + for (const el of els) { if (el.offsetParent !== null) return true; } + return false; +}; + +/* ---------------- update ---------------- */ +W2D.update = function (dt, now) { + const sp = 0.14 * dt; // px per ms + const K = W2D.keys, V = W2D.vk || {}; + let dx = 0, dy = 0; + if (K['w'] || V.up) dy -= 1; + if (K['s'] || V.down) dy += 1; + if (K['a'] || V.left) dx -= 1; + if (K['d'] || V.right) dx += 1; + + // click-to-move steering when no keys held + if (!dx && !dy && W2D.target) { + const tdx = W2D.target.x - W2D.px, tdy = W2D.target.y - W2D.py; + const td = Math.hypot(tdx, tdy); + if (td < 8) { W2D.target = null; } + else { dx = tdx / td; dy = tdy / td; } + } + if ((K['w'] || K['a'] || K['s'] || K['d'] || V.up || V.down || V.left || V.right)) { W2D.target = null; } + + W2D.moving = !!(dx || dy); + if (W2D.moving) { + const len = Math.hypot(dx, dy) || 1; + const nx = W2D.px + dx / len * sp, ny = W2D.py + dy / len * sp; + const r = 8; // feet box radius + if (!W2D.blockedAt(nx + Math.sign(dx) * r, W2D.py)) W2D.px = nx; + if (!W2D.blockedAt(W2D.px, ny + Math.sign(dy) * r)) W2D.py = ny; + W2D.dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 1 : 3) : (dy > 0 ? 2 : 0); + W2D.anim += dt; + W2D.trail.push({ x: W2D.px, y: W2D.py }); + if (W2D.trail.length > 40) W2D.trail.shift(); + W2D.posCache[W2D.cur] = { x: W2D.px, y: W2D.py }; + } + + // arrived near a clicked interactable? trigger it + if (W2D.pending && W2D.pending.ent) { + const pe = W2D.pending.ent; + if (Math.hypot(pe.x - W2D.px, pe.y - W2D.py) < 46) { + const p = W2D.pending; W2D.pending = null; W2D.target = null; + if (p.kind === 'npc') W2D.openNpcMenu(pe.id); + else if (p.kind === 'spot') UI.runAction(pe.act); + else if (p.kind === 'exit') Game.travelTo(pe.to); + else if (p.kind === 'roam') { /* walking into beasts triggers battle via proximity */ } + } + } + + // camera + const tx = W2D.px - W2D.VW / 2, ty = W2D.py - W2D.VH / 2; + W2D.cam.x += (tx - W2D.cam.x) * 0.12; W2D.cam.y += (ty - W2D.cam.y) * 0.12; + W2D.cam.x = Math.max(0, Math.min(W2D.grid.w * W2D.T - W2D.VW, W2D.cam.x)); + W2D.cam.y = Math.max(0, Math.min(W2D.grid.h * W2D.T - W2D.VH, W2D.grid.h * W2D.T > W2D.VH ? W2D.cam.y : -(W2D.VH - W2D.grid.h * W2D.T) / 2)); + + // roamer AI + for (const e of W2D.ents) { + if (e.kind !== 'roam' || e.dead) continue; + if (e.immune && now < e.immune) continue; + e.cool -= dt; + if (e.cool <= 0) { + e.cool = 420 + Math.random() * 500; + const pdx = W2D.px - e.x, pdy = W2D.py - e.y; + const dist = Math.hypot(pdx, pdy); + let mx = 0, my = 0; + if (dist < 6 * W2D.T) { mx = Math.sign(pdx); my = Math.sign(pdy); } + else { const r = Math.random(); mx = r < .33 ? 1 : r < .66 ? -1 : 0; my = Math.random() < .5 ? (Math.random() < .5 ? 1 : -1) : 0; } + const nxp = (e.gx + mx) * W2D.T + 16, nyp = (e.gy + my) * W2D.T + 16; + if (!W2D.blockedAt(nxp, nyp)) { e.gx += mx; e.gy += my; e.x = nxp; e.y = nyp; } + // engage + if (dist < 30 && !G.inBattle) { + W2D.engaged = e; + e.immune = now + 4000; + const danger = LOCS[W2D.cur].danger || 1; + const ids = Math.random() < 0.3 ? [e.id, e.id] : [e.id]; + Game.startBattle({ ids, scale: 0.8 + danger * 0.12, meta: { kind: 'wild' } }); + } + } + } + + // portal auto-travel + const near = W2D.nearest(); + if (near && near.ent.kind === 'exit' && near.dist < 22) { + const d = near.ent; d.cool = d.cool || 0; + if (now - (d.last || 0) > 1500) { d.last = now; Game.travelTo(d.to); } + } + + W2D.updateParts(dt, now); +}; + +/* ---------------- interaction ---------------- */ +W2D.nearest = function () { + let best = null; + const consider = (ent, label) => { + const dist = Math.hypot(ent.x - W2D.px, ent.y - W2D.py); + if (dist < 46 && (!best || dist < best.dist)) best = { ent, dist, label }; + }; + for (const e of W2D.ents) { + if (e.kind === 'npc') consider(e, 'Talk'); + if (e.kind === 'spot') consider(e, e.act.label); + } + for (const ex of W2D.grid.exits) { + const L2 = LOCS[ex.to]; + if (L2.hidden && !G.flags[L2.hidden]) continue; + if (L2.nightOnly && !TimeSys.isNight()) continue; + consider({ kind: 'exit', to: ex.to, x: ex.x * W2D.T + 16, y: ex.y * W2D.T + 16 }, 'To ' + L2.name); + } + return best; +}; + +W2D.interact = function () { + const near = W2D.nearest(); + if (!near) return; + const e = near.ent; + if (e.kind === 'npc') { W2D.openNpcMenu(e.id); } + else if (e.kind === 'spot') { UI.runAction(e.act); } + else if (e.kind === 'exit') { Game.travelTo(e.to); } +}; + +W2D.openNpcMenu = function (id) { + const n = NPCS[id]; + const btns = []; + if (n.dlg) btns.push({ label: '💬 Talk', primary: true, fn: () => { UI.closeModal(); UI.talk(id); } }); + if (n.shop) btns.push({ label: '🛒 Trade', fn: () => { UI.closeModal(); UI.openShop(id); } }); + if (n.spar !== false) btns.push({ label: '⚔️ Spar', fn: () => { UI.closeModal(); Game.spar(id); } }); + btns.push({ label: '🎁 Give gift', fn: () => { UI.closeModal(); UI.openGiftPicker(id); } }); + btns.push({ label: 'Leave', fn: () => UI.closeModal() }); + UI.modal(`${Util.esc(n.name)} — ${Util.esc(n.title || '')}`, + `

${Util.esc(n.role || '')}

`, btns); +}; + +/* ---------------- drawing ---------------- */ +W2D.draw = function (now) { + const c = W2D.ctx; if (!c) return; + const gr = W2D.grid, T = W2D.T; + c.imageSmoothingEnabled = false; + c.clearRect(0, 0, W2D.VW, W2D.VH); + c.save(); + c.translate(-Math.round(W2D.cam.x), -Math.round(W2D.cam.y)); + const safe = (f) => { try { f(); } catch (e) { W2D._warned = W2D._warned || {}; const m = e && e.message; if (!W2D._warned[m]) { W2D._warned[m] = 1; console.warn('[world draw]', m); } } }; + try { c.drawImage(W2D.ground, 0, 0); } catch (_) {} + + // animated water shimmer over water tiles + safe(() => { + { + const wl = W2D.ground._water || []; + c.strokeStyle = 'rgba(190,225,245,.22)'; c.lineWidth = 2; + for (let i = 0; i < wl.length; i++) { + const wx = wl[i][0], wy = wl[i][1]; + if (wx < W2D.cam.x - T || wx > W2D.cam.x + W2D.VW || wy < W2D.cam.y - T || wy > W2D.cam.y + W2D.VH) continue; + const ph = ((now / 900) + i * .37) % 1; + const yy = wy + 8 + ph * (T - 16); + c.beginPath(); c.moveTo(wx + 8 + Math.sin(i + now / 600) * 4, yy); c.lineTo(wx + 20 + Math.sin(i + now / 600) * 4, yy); c.stroke(); + } + c.lineWidth = 1; + } + }); + + // move-target marker + safe(() => { + if (W2D.target) { + c.strokeStyle = 'rgba(216,179,106,.85)'; + c.lineWidth = 2; + c.beginPath(); c.arc(W2D.target.x, W2D.target.y, 6 + Math.sin(now / 150) * 1.5, 0, 7); c.stroke(); + c.lineWidth = 1; + } + }); + + // exit arches + safe(() => { + for (const ex of gr.exits) { + const L2 = LOCS[ex.to]; + if (L2.hidden && !G.flags[L2.hidden]) continue; + const X = ex.x * T, Y = ex.y * T; + c.fillStyle = 'rgba(216,179,106,.9)'; + c.fillRect(X + 4, Y + (ex.tag === 'N' ? 2 : T - 10), T - 8, 8); + c.fillStyle = '#1a1408'; + c.font = 'bold 10px serif'; c.textAlign = 'center'; + c.fillText('➜', X + T / 2, Y + (ex.tag === 'N' ? 10 : T - 3)); + } + }); + + // sort drawables by y + safe(() => { + const draws = []; + for (const e of W2D.ents) { + if (e.kind === 'roam' && e.dead) continue; + draws.push(e); + } + draws.push({ kind: 'player', x: W2D.px, y: W2D.py }); + draws.sort((a, b) => a.y - b.y); + + for (const d of draws) { + if (d.kind === 'npc') W2D.drawNpc(c, d, now); + else if (d.kind === 'spot') W2D.drawSpot(c, d, now); + else if (d.kind === 'roam') W2D.drawRoam(c, d, now); + else W2D.drawPlayer(c, now); + } + }); + + // follower + safe(() => { + if (G.party && G.party.length && W2D.trail.length > 12) { + const p = W2D.trail[Math.max(0, W2D.trail.length - 14)]; + W2D.figure(c, p.x, p.y + 4, 0.8, '#5d8a3f', now, false); + } + }); + + // prompt + safe(() => { + const near = W2D.nearest(); + if (near) { + const label = near.ent.kind === 'npc' ? `E · Talk — ${NPCS[near.ent.id].name}` : near.ent.kind === 'spot' ? `E · ${near.label}` : `E · ${near.label}`; + W2D.bubble(c, near.ent.x, near.ent.y - 34, label); + } + }); + + // night tint + lantern glow + safe(() => { + const hr = G.time.hour; + let dark = 0; + if (hr >= 20 || hr < 5) dark = 0.5; else if (hr >= 18) dark = (hr - 18) * 0.16; else if (hr < 7) dark = (7 - hr) * 0.12; + if (dark > 0) { + c.fillStyle = `rgba(8,12,32,${dark})`; + c.fillRect(W2D.cam.x, W2D.cam.y, W2D.VW, W2D.VH); + c.globalCompositeOperation = 'lighter'; + for (let y = 0; y < gr.h; y++) for (let x = 0; x < gr.w; x++) { + const t = gr.g[y][x]; + if (t === 12 || t === 17) { + const g2 = c.createRadialGradient(x * T + T / 2, y * T + T / 2, 4, x * T + T / 2, y * T + T / 2, 60); + g2.addColorStop(0, 'rgba(232,160,60,.5)'); g2.addColorStop(1, 'rgba(232,160,60,0)'); + c.fillStyle = g2; c.fillRect(x * T - 60, y * T - 60, 152, 152); + } + } + c.globalCompositeOperation = 'source-over'; + } + }); + + safe(() => W2D.drawParts(c, now)); + safe(() => W2D.drawVignette(c)); + c.restore(); + + // banner + if (W2D.banner > 0) { + W2D.banner -= 16; + const a = Math.min(1, W2D.banner / 600); + c.fillStyle = `rgba(10,14,20,${0.55 * a})`; + c.fillRect(W2D.VW / 2 - 170, 14, 340, 44); + c.fillStyle = `rgba(216,179,106,${a})`; + c.font = '20px serif'; c.textAlign = 'center'; + c.fillText(LOCS[W2D.cur].name, W2D.VW / 2, 43); + } +}; + +W2D.figure = function (c, x, y, s, robe, now, walking) { + const bob = walking ? Math.sin(now / 90) * 1.6 : 0; + c.fillStyle = 'rgba(0,0,0,.3)'; + c.beginPath(); c.ellipse(x, y + 10 * s, 9 * s, 3.5 * s, 0, 0, 7); c.fill(); + // legs + c.fillStyle = '#2c2c34'; + const lg = walking ? Math.sin(now / 90) * 3 : 0; + c.fillRect(x - 5 * s, y + 2 * s + lg * s * 0.4, 4 * s, 8 * s); + c.fillRect(x + 1 * s, y + 2 * s - lg * s * 0.4, 4 * s, 8 * s); + // robe + c.fillStyle = robe; + c.fillRect(x - 7 * s, y - 8 * s + bob, 14 * s, 12 * s); + c.fillStyle = 'rgba(216,179,106,.85)'; + c.fillRect(x - 7 * s, y + 1 * s + bob, 14 * s, 2 * s); + // head + c.fillStyle = '#e8c39a'; + c.beginPath(); c.arc(x, y - 12 * s + bob, 5 * s, 0, 7); c.fill(); + c.fillStyle = '#1d1d22'; + c.beginPath(); c.arc(x, y - 14 * s + bob, 5 * s, Math.PI, 0); c.fill(); // hair +}; + +W2D.drawPlayer = function (c, now) { + const robe = G.player.gender === 'f' ? '#8a3f5d' : '#3f5d8a'; + W2D.figure(c, W2D.px, W2D.py, 1, robe, now, W2D.moving); + // sword on back + c.save(); c.translate(W2D.px + 8, W2D.py - 4); c.rotate(-0.6); + c.fillStyle = '#9aa4ad'; c.fillRect(0, -2, 14, 3); + c.fillStyle = '#5a4632'; c.fillRect(-4, -3, 4, 5); + c.restore(); +}; + +W2D.drawNpc = function (c, e, now) { + const n = NPCS[e.id]; + W2D.figure(c, e.x, e.y, 1, n.rob || '#5d6e8a', now, false); + c.font = '11px serif'; c.textAlign = 'center'; + c.fillStyle = 'rgba(0,0,0,.5)'; + const w = c.measureText(n.name).width + 10; + c.fillRect(e.x - w / 2, e.y - 34, w, 15); + c.fillStyle = '#e8dcc2'; + c.fillText(n.name, e.x, e.y - 23); +}; + +W2D.drawSpot = function (c, e, now) { + const pulse = 0.5 + Math.sin(now / 300) * 0.2; + c.fillStyle = `rgba(216,179,106,${pulse * 0.35})`; + c.beginPath(); c.arc(e.x, e.y, 14, 0, 7); c.fill(); + c.font = '16px serif'; c.textAlign = 'center'; + const glyph = { herb: '🌿', mine: '⛏️', meditate: '🧘', dummy: '🥋', secttasks: '📋', board: '📌', gamble: '🎲', pickpocket: '🖐️', camp: '🏕️', fish: '🎣' }[e.act.id] || '✨'; + c.fillText(glyph, e.x, e.y + 6); +}; + +W2D.drawRoam = function (c, e, now) { + const en = ENEMIES[e.id]; + const bob = Math.sin(now / 200 + e.gx) * 2; + c.fillStyle = 'rgba(0,0,0,.3)'; + c.beginPath(); c.ellipse(e.x, e.y + 10, 9, 3.5, 0, 0, 7); c.fill(); + c.fillStyle = en.color || '#7a3b30'; + c.fillRect(e.x - 8, e.y - 10 + bob, 16, 16); + c.fillStyle = 'rgba(255,255,255,.15)'; + c.fillRect(e.x - 8, e.y - 10 + bob, 16, 4); + c.fillStyle = '#e8dcc2'; c.font = '12px serif'; c.textAlign = 'center'; + c.fillText(en.glyph, e.x, e.y + 3 + bob); + c.fillStyle = '#e07a6a'; c.fillRect(e.x - 9, e.y - 18 + bob, 18, 3); + c.fillStyle = '#7dc25c'; c.fillRect(e.x - 9, e.y - 18 + bob, 18 * Math.min(1, (e.hp || 30) / 30), 3); +}; + +W2D.bubble = function (c, x, y, text) { + c.font = '12px serif'; c.textAlign = 'center'; + const w = c.measureText(text).width + 16; + c.fillStyle = 'rgba(12,16,24,.88)'; + c.strokeStyle = 'rgba(216,179,106,.7)'; + c.beginPath(); + c.roundRect ? c.roundRect(x - w / 2, y - 14, w, 22, 6) : c.rect(x - w / 2, y - 14, w, 22); + c.fill(); c.stroke(); + c.fillStyle = '#f0e6cc'; + c.fillText(text, x, y + 1); +}; + +/* ================================================================ + SPRITES v2 — larger, layered, animated (overrides earlier draws) + ================================================================ */ +W2D.figure = function (c, x, y, s, robe, now, walking, hair, dir) { + s = s * 1.35; // bigger characters + hair = hair || '#241d18'; + dir = dir === undefined ? 2 : dir; + const phase = walking ? Math.floor(now / 130) % 2 : 0; + const bob = walking ? Math.sin(now / 110) * 1.8 : Math.sin(now / 500) * .6; + // shadow + c.fillStyle = 'rgba(0,0,0,.32)'; + c.beginPath(); c.ellipse(x, y + 13 * s * .8, 10 * s, 4 * s, 0, 0, 7); c.fill(); + // legs + c.fillStyle = '#33333c'; + const lo = walking ? (phase ? 3.5 : -3.5) * s * .5 : 0; + c.fillRect(x - 6 * s, y + 4 * s + lo * .4, 5 * s, 12 * s); + c.fillRect(x + 1 * s, y + 4 * s - lo * .4, 5 * s, 12 * s); + // robe: trapezoid with side shade + const grd = c.createLinearGradient(x - 9 * s, 0, x + 9 * s, 0); + grd.addColorStop(0, robe); + grd.addColorStop(.55, robe); + grd.addColorStop(1, 'rgba(0,0,0,.28)'); + c.fillStyle = robe; + c.beginPath(); + c.moveTo(x - 7 * s, y - 10 * s + bob); + c.lineTo(x + 7 * s, y - 10 * s + bob); + c.lineTo(x + 9 * s, y + 6 * s + bob * .4); + c.lineTo(x - 9 * s, y + 6 * s + bob * .4); + c.closePath(); c.fill(); + c.fillStyle = 'rgba(255,255,255,.09)'; + c.beginPath(); c.moveTo(x - 7 * s, y - 10 * s + bob); c.lineTo(x - 2 * s, y - 10 * s + bob); c.lineTo(x - 3 * s, y + 6 * s); c.lineTo(x - 9 * s, y + 6 * s); c.closePath(); c.fill(); + // sash + c.fillStyle = '#d8b36a'; + c.fillRect(x - 8 * s, y - 1 * s + bob, 16 * s, 2.6 * s); + // arms + c.fillStyle = robe; + c.fillRect(x - 10 * s, y - 8 * s + bob, 3.4 * s, 11 * s); + c.fillRect(x + 6.6 * s, y - 8 * s + bob, 3.4 * s, 11 * s); + c.fillStyle = '#e8c39a'; + c.fillRect(x - 10 * s, y + 2.4 * s + bob, 3.4 * s, 2.6 * s); + c.fillRect(x + 6.6 * s, y + 2.4 * s + bob, 3.4 * s, 2.6 * s); + // head + c.fillStyle = '#e8c39a'; + c.beginPath(); c.arc(x, y - 16 * s + bob, 7 * s, 0, 7); c.fill(); + // hair cap + bun + c.fillStyle = hair; + c.beginPath(); c.arc(x, y - 17.5 * s + bob, 7.2 * s, Math.PI * 1.02, Math.PI * 1.98); c.fill(); + c.beginPath(); c.arc(x, y - 25 * s + bob, 3.4 * s, 0, 7); c.fill(); // topknot + // face by direction + if (dir !== 0) { + c.fillStyle = '#20242a'; + const ex = dir === 1 ? 2.4 * s : dir === 3 ? -2.4 * s : 0; + if (dir === 2) { + c.beginPath(); c.arc(x - 2.4 * s, y - 15 * s + bob, 1.15 * s, 0, 7); c.fill(); + c.beginPath(); c.arc(x + 2.4 * s, y - 15 * s + bob, 1.15 * s, 0, 7); c.fill(); + } else { + c.beginPath(); c.arc(x + ex, y - 15 * s + bob, 1.15 * s, 0, 7); c.fill(); + } + c.strokeStyle = 'rgba(160,90,70,.7)'; c.beginPath(); c.arc(x + ex * .4, y - 12.6 * s + bob, 1.6 * s, .15 * Math.PI, .85 * Math.PI); c.stroke(); + } +}; + +W2D.drawPlayer = function (c, now) { + const robe = G.player.gender === 'f' ? '#8a3f5d' : '#3f5d8a'; + W2D.figure(c, W2D.px, W2D.py, 1, robe, now, W2D.moving, G.player.gender === 'f' ? '#3a2a22' : '#171310', W2D.dir); + // flowing headband ribbon + c.save(); + c.strokeStyle = 'rgba(216,179,106,.9)'; c.lineWidth = 2; + const fx = W2D.dir === 1 ? -1 : 1; + c.beginPath(); + c.moveTo(W2D.px + fx * 5, W2D.py - 26 + Math.sin(now / 200) * 1.5); + c.quadraticCurveTo(W2D.px + fx * 16, W2D.py - 30 + Math.sin(now / 170) * 3, W2D.px + fx * 24, W2D.py - 24 + Math.sin(now / 140) * 4); + c.stroke(); c.lineWidth = 1; + // sword on back + c.save(); c.translate(W2D.px + 9, W2D.py - 6); c.rotate(-0.65); + c.fillStyle = '#453729'; c.fillRect(-2, -3, 20, 4); // scabbard + c.fillStyle = '#aab4bd'; c.fillRect(14, -2.4, 9, 2.8); // exposed blade + c.fillStyle = '#d8b36a'; c.fillRect(-4, -4, 3, 6); // guard + c.restore(); +}; + +W2D.drawNpc = function (c, e, now) { + const n = NPCS[e.id]; + W2D.figure(c, e.x, e.y, 1, n.hue || '#5d6e8a', now, false, n.gender === 'f' ? '#33241e' : '#181310', 2); + c.font = '12px serif'; c.textAlign = 'center'; + const label = n.name; + const w = c.measureText(label).width + 12; + c.fillStyle = 'rgba(10,13,18,.72)'; + c.beginPath(); if (c.roundRect) c.roundRect(e.x - w / 2, e.y - 42, w, 17, 5); else c.rect(e.x - w / 2, e.y - 42, w, 17); c.fill(); + c.strokeStyle = 'rgba(216,179,106,.45)'; c.stroke(); + c.fillStyle = '#efe6cf'; + c.fillText(label, e.x, e.y - 30); +}; + +W2D.drawRoam = function (c, e, now) { + const en = ENEMIES[e.id]; + const near = Math.hypot(W2D.px - e.x, W2D.py - e.y) < 100; + const bob = Math.sin(now / 220 + e.gx) * 2.5; + c.fillStyle = 'rgba(0,0,0,.3)'; + c.beginPath(); c.ellipse(e.x, e.y + 12, 11, 4, 0, 0, 7); c.fill(); + if (near) { // aggro telegraph + c.strokeStyle = `rgba(220,80,60,${.5 + Math.sin(now / 120) * .3})`; + c.lineWidth = 2; c.beginPath(); c.arc(e.x, e.y - 4, 17, 0, 7); c.stroke(); c.lineWidth = 1; + } + // body w/ outline + c.fillStyle = en.color || '#7a3b30'; + c.strokeStyle = 'rgba(0,0,0,.55)'; + c.beginPath(); c.arc(e.x, e.y - 4 + bob, 13, 0, 7); c.fill(); c.stroke(); + c.fillStyle = 'rgba(255,255,255,.14)'; + c.beginPath(); c.arc(e.x - 4, e.y - 9 + bob, 6, 0, 7); c.fill(); + c.fillStyle = '#f2e9d4'; c.font = 'bold 14px serif'; c.textAlign = 'center'; + c.fillText(en.glyph, e.x, e.y + bob + 1); + // hp bar + const hpPct = Math.min(1, (e.hp || 30) / 30); + c.fillStyle = 'rgba(0,0,0,.6)'; c.fillRect(e.x - 13, e.y - 23 + bob, 26, 4); + c.fillStyle = hpPct > .5 ? '#7dc25c' : hpPct > .25 ? '#d8b36a' : '#c94f42'; + c.fillRect(e.x - 12, e.y - 22 + bob, 24 * hpPct, 2); +}; + +/* ---------------- ambient particles ---------------- */ +W2D.initParts = function () { + const st = W2D.grid.style; + let mode = null; + if (['village', 'temple', 'bamboo', 'valley'].includes(st)) mode = 'petal'; + if (st === 'market' || st === 'tomb') mode = 'ember'; + if (st === 'sect' || st === 'mountain') mode = 'leaf'; + W2D.partMode = mode; + W2D.parts = []; + W2D._partT = 0; +}; +W2D.updateParts = function (dt, now) { + if (!W2D.partMode) return; + W2D._partT -= dt; + if (W2D._partT <= 0 && W2D.parts.length < 46) { + W2D._partT = 130 + Math.random() * 260; + const m = W2D.partMode; + const p = { + x: W2D.cam.x + Math.random() * W2D.VW, + y: m === 'ember' ? W2D.cam.y + W2D.VH + 10 : W2D.cam.y - 10, + vx: m === 'petal' ? -14 - Math.random() * 16 : m === 'leaf' ? -20 - Math.random() * 14 : (Math.random() * 10 - 5), + vy: m === 'ember' ? -26 - Math.random() * 22 : 22 + Math.random() * 20, + rot: Math.random() * 6.28, vr: (Math.random() - .5) * 4, + life: 1, decay: .12 + Math.random() * .1, ph: Math.random() * 6.28 + }; + W2D.parts.push(p); + } + for (const p of W2D.parts) { + p.life -= p.decay * dt / 1000; + p.x += p.vx * dt / 100; p.y += p.vy * dt / 100; + p.rot += p.vr * dt / 1000; + if (W2D.partMode !== 'ember') p.x += Math.sin(now / 700 + p.ph) * .5; + } + W2D.parts = W2D.parts.filter(p => p.life > 0); +}; +W2D.drawParts = function (c, now) { + for (const p of W2D.parts) { + c.save(); c.translate(p.x, p.y); c.globalAlpha = Math.max(0, Math.min(1, p.life)); + if (W2D.partMode === 'petal') { + c.rotate(p.rot); c.fillStyle = '#d98aa8'; + c.beginPath(); c.ellipse(0, 0, 4, 2.4, 0, 0, 7); c.fill(); + c.fillStyle = 'rgba(255,255,255,.35)'; c.beginPath(); c.ellipse(-1, -.6, 1.8, 1, 0, 0, 7); c.fill(); + } else if (W2D.partMode === 'leaf') { + c.rotate(p.rot); c.fillStyle = '#7fa050'; + c.beginPath(); c.ellipse(0, 0, 4.6, 2, 0, 0, 7); c.fill(); + } else { // ember + const g2 = c.createRadialGradient(0, 0, 0, 0, 0, 5); + g2.addColorStop(0, 'rgba(245,180,90,.95)'); g2.addColorStop(1, 'rgba(245,120,40,0)'); + c.fillStyle = g2; c.beginPath(); c.arc(0, 0, 5, 0, 7); c.fill(); + } + c.restore(); + } + c.globalAlpha = 1; +}; + +/* vignette overlay (call inside translated context, before banner) */ +W2D.drawVignette = function (c) { + const vg = c.createRadialGradient(W2D.VW / 2, W2D.VH / 2, W2D.VH * .38, W2D.VW / 2, W2D.VH / 2, W2D.VH * .82); + vg.addColorStop(0, 'rgba(0,0,0,0)'); + vg.addColorStop(1, 'rgba(4,6,12,.42)'); + c.fillStyle = vg; + c.fillRect(W2D.cam.x, W2D.cam.y, W2D.VW, W2D.VH); +}; + +/* flat fallback ground (used only if the rich renderer throws) */ +W2D._flatGround = function (grid) { + const T = W2D.T; + const cv = document.createElement('canvas'); + cv.width = grid.w * T; cv.height = grid.h * T; + const c = cv.getContext('2d'); + const COLS = { 0: '#354c33', 1: '#5d5142', 2: '#1c3a52', 3: '#274a33', 4: '#6e6152', 5: '#7a3b30', 6: '#354c33', 7: '#4d565f', 8: '#6e5236', 9: '#5c5648', 10: '#3c4048', 11: '#4a4030', 12: '#3c4048', 13: '#3c4048', 14: '#4a505a', 15: '#3c4048', 16: '#3c4048', 17: '#3c4048', 18: '#3c4048', 19: '#1c3a52', 20: '#31472f' }; + for (let y = 0; y < grid.h; y++) for (let x = 0; x < grid.w; x++) { + c.fillStyle = COLS[grid.g[y][x]] || '#354c33'; + c.fillRect(x * T, y * T, T, T); + } + return cv; +}; + +/* expose for smoke tests */ +if (typeof module !== 'undefined' && module.exports) module.exports = W2D; diff --git a/server.js b/server.js new file mode 100644 index 0000000..d32c612 --- /dev/null +++ b/server.js @@ -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)')); diff --git a/test/smoke.mjs b/test/smoke.mjs new file mode 100644 index 0000000..0c8e2d8 --- /dev/null +++ b/test/smoke.mjs @@ -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();