Files
jianghu-chronicles/js/game.js
T
deepseek 8680f79102 Initial commit — Jianghu Chronicles: Road of the Wandering Blade
Original open-world wuxia browser RPG:
- Graphical 48px tile world (12 locations) with walkable character,
  NPC interaction, action spots, roaming enemies, travel portals
- Tactical 10x7 grid battles: shapes, statuses, internals, 30+ techniques
  incl. tier-V ultimates & support arts across 6 weapon types
- Full equipment: weapon/head/body/feet/2 accessories, 5 tiers, smithing
- 6-chapter main story + side jobs board + bounty hunts + tournament
- Crafting (smith/alchemy), fishing minigame, gambling, pickpocketing
- Companions with chemistry passives, affection, romance, sects
- Achievements, monster codex, day/night cycle, endings
- 25-test headless smoke suite; no-cache static server included
2026-08-23 07:01:08 +00:00

1032 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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',
`<p class="dlg-text">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 <b>Elder Chen</b> to begin.</p>`,
[{ 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 <span class="goldtx">${Util.esc(it.name)}</span> ×${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'} <b>${Math.abs(n)}</b> 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 <span class="goldtx">${INTERNALS[t].name}</span>.`, '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: <span class="goldtx">${LIGHTNESS[t].name}</span>.`, 'sys');
Game.removeItem(id, 1); UI.refresh();
}
},
learnTechnique(tid, free) {
if (G.player.techs[tid]) return;
G.player.techs[tid] = 10;
Log.add(`Learned <span class="goldtx">${TECHNIQUES[tid].name}</span>!`, '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(`<b>${Util.esc(NPCS[npcId].name)}</b> teaches you <span class="goldtx">${TECHNIQUES[t.tech].name}</span>.`, 'sys'); }
if (t.internal) {
G.player.knownInternals.push(t.internal); G.player.internal = t.internal;
Log.add(`<b>${Util.esc(NPCS[npcId].name)}</b> transmits the <span class="goldtx">${INTERNALS[t.internal].name}</span>.`, '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('<b>Hong</b> presses his palms together \u2014 ten thousand rivers pour into your bones. Learned <span class="goldtx">Ten Thousand Rivers Palms</span>!', '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(`<b>🏆 Achievement unlocked:</b> ${ACHIEVEMENTS[id].name}`, 'sys');
Sfx.play('levelup');
},
/* ---------------- EXP & levels ---------------- */
addExp(n) {
const p = G.player;
p.exp += n;
Log.add(`Gained <b>${n}</b> 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(`<b>LEVEL UP!</b> 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 <b>${Util.esc(target.name)}</b>. (${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 <b>${loss}</b> 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(`<b>${UTIL_INT_NAME(p.internal)}</b> 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', '<p class="dlg-text">A bored steward slides three wooden tallies across the desk.</p>', 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 <span class="goldtx">${DATA.ITEMS[up.to].name}</span>!`, '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 <span class="goldtx">${DATA.ITEMS[r.out].name}</span> ×${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('<b>CHAMPION!</b> 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', `<p class="dlg-text">Round ${G.tourney.stage} won! Your next opponent awaits: <b>${TOURNEY_LADDER[G.tourney.stage].name}</b>.</p>`,
[{ label: 'Fight on', fn: () => { UI.closeModal(); Game.nextTourneyFight(); } }, { label: 'Withdraw (keep winnings)', fn: () => UI.closeModal() }]);
}
},
dukangAmbush() {
UI.modal('The Pavilion', '<p class="dlg-text">Behind the arena, a man waits beneath a silver-paper umbrella. Du Kang smiles without warmth.<br><br>\u201cThe celebrated ridge-clearer! The Society offers glory, wealth\u2026 and long life. Refuse, and become an anecdote.\u201d</p>',
[
{ 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', '<p class="dlg-text">\u201cWise. The Ledger in the old tomb \u2014 bring it to us, and names, ranks, silver follow. Fail, and\u2026 well.\u201d<br><br>You could play along\u2026</p>',
[ { 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(`<b>${Util.esc(NPCS[npcId].name)}</b> 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(`<b>New quest:</b> ${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: <i>${QUESTS[qid].name}</i>`, '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(`<b>Quest complete:</b> ${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)); }