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
1032 lines
46 KiB
JavaScript
1032 lines
46 KiB
JavaScript
'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)); }
|