'use strict'; /* ============================================================ GAME โ actions, progression, quests, economy, romance, endings ============================================================ */ const Game = { /* ---------------- new game ---------------- */ newGame(opts) { G.player = makePlayer(opts); G.loc = 'l_village'; G.discovered = { l_village: true }; G.flags = { mainChapter: 1, ach: {}, seen: {}, fishTotal: 0 }; if (opts.hard) G.flags.hardMode = true; G.aff = {}; G.rep = { fame: 0, infamy: 0, azure: 0, temple: 0, serpent: 0, fist: 0, garrison: 0, underground: 0 }; G.quests = {}; G.party = []; G.allies = []; G.time = { day: 1, hour: 8 }; G.log = []; G.daily = defaultDaily(); Game.rollBounties(); Log.clearDom(); Log.add(`Day 1 โ ${G.player.name} steps out of Willow Creek Village. The jianghu awaits.`, 'sys'); SaveSys.autosave(); UI.showScreen('world'); UI.setTab('scene'); UI.refresh(); UI.modal('Prologue', `
Willow Creek never had use for heroes \u2014 until the wolves came down off Cloudmist Ridge,\nbold as tax collectors.\n\nYou are ${Util.esc(G.player.name)}, ${G.player.gender === 'f' ? 'daughter' : 'son'} of this village, and this morning\nyou wrapped your hands, checked your blade, and decided the wide road was calling.\n\nSpeak with Elder Chen to begin.
`, [{ label: 'Begin the journey', fn: () => {} }]); }, /* ---------------- inventory / money ---------------- */ addItem(id, q) { q = q || 1; const it = DATA.ITEMS[id]; if (!it) return; const stackable = !['weapon', 'body', 'head', 'feet'].includes(it.type); const ex = stackable && G.player.inv.find(s => s.id === id); if (ex) ex.q += q; else G.player.inv.push({ id, q }); Log.add(`Obtained ${Util.esc(it.name)} ร${q}.`); }, removeItem(id, q) { q = q || 1; const slot = G.player.inv.find(s => s.id === id); if (!slot) return false; slot.q -= q; if (slot.q <= 0) G.player.inv = G.player.inv.filter(s => s !== slot); return true; }, hasItem(id, q) { q = q || 1; const s = G.player.inv.find(x => x.id === id); return !!s && s.q >= q; }, countItem(id) { const s = G.player.inv.find(x => x.id === id); return s ? s.q : 0; }, addSilver(n) { G.player.silver += n; Sfx.play('coin'); Log.add(`${n >= 0 ? 'Gained' : 'Lost'} ${Math.abs(n)} silver.`, n >= 0 ? 'good' : 'bad'); if (G.player.silver >= 2000) Game.unlockAch('fat_purse'); }, spendSilver(n) { if (G.player.silver < n) { Toast.show('Not enough silver.'); return false; } G.player.silver -= n; Sfx.play('coin'); return true; }, equip(slot, invIndex) { const entry = G.player.inv[invIndex]; if (!entry) return; const it = DATA.ITEMS[entry.id]; const target = it.slot; if (!target) return; // weapons/armor are unique items; swap const prev = G.player.equip[target]; G.player.equip[target] = entry.id; Game.removeItem(entry.id, 1); if (prev) Game.addItem(prev, 1); Stats.recalc(G.player); G.player.hp = Math.min(G.player.hp, G.player.d.maxHp); G.player.mp = Math.min(G.player.mp, G.player.d.maxMp); Sfx.play('click'); UI.refresh(); }, unequip(slot) { const cur = G.player.equip[slot]; if (!cur) return; G.player.equip[slot] = null; Game.addItem(cur, 1); Stats.recalc(G.player); UI.refresh(); }, useItem(invIndex) { const entry = G.player.inv[invIndex]; if (!entry) return; const it = DATA.ITEMS[entry.id]; if (it.type === 'manual') { Game.learnManual(entry.id); return; } if (it.type !== 'use' || !it.use) { Toast.show('Cannot use that here.'); return; } const u = it.use; const p = G.player; if (u.hp) { p.hp = Math.min(p.d.maxHp, p.hp + u.hp); Log.add(`Restored ${u.hp} health.`, 'good'); } if (u.mp) { p.mp = Math.min(p.d.maxMp, p.mp + u.mp); Log.add(`Restored ${u.mp} inner energy.`, 'good'); } if (u.cure) { Toast.show('Use antidotes during battle.'); return; } if (u.permHp) { if ((G.flags.tonicsUsed || 0) >= 10) { Toast.show('Your body can absorb no more tonic.'); return; } G.flags.tonicsUsed = (G.flags.tonicsUsed || 0) + 1; p.permBonuses.hp += u.permHp; Stats.recalc(p); Log.add(`Maximum health permanently +${u.permHp}!`, 'sys'); } if (u.permWit) { if ((G.flags.fragsUsed || 0) >= 3) { Toast.show('The fragments blur together now.'); return; } G.flags.fragsUsed = (G.flags.fragsUsed || 0) + 1; p.attrs.wit += 1; Stats.recalc(p); Log.add('Insight deepens. Wit permanently +1!', 'sys'); } if (u.buff || u.coatPoison) { Toast.show('Save it for battle.'); return; } Game.removeItem(entry.id, 1); Sfx.play('heal'); UI.refresh(); }, learnManual(id) { const it = DATA.ITEMS[id]; const t = it.teach; if (TECHNIQUES[t]) { if (G.player.techs[t]) { Toast.show('You already know this art.'); return; } Game.learnTechnique(t); Game.removeItem(id, 1); } else if (INTERNALS[t]) { if (G.player.knownInternals.includes(t)) { Toast.show('Already cultivated.'); return; } G.player.knownInternals.push(t); G.player.internal = t; G.player.internalLv = Math.max(1, G.player.internalLv); Stats.recalc(G.player); Log.add(`You internalize the ${INTERNALS[t].name}.`, 'sys'); if (t === 'i_sun') Game.unlockAch('sun_dawn'); Game.removeItem(id, 1); UI.refresh(); } else if (LIGHTNESS[t]) { if (G.player.knownLights.includes(t)) { Toast.show('Already mastered.'); return; } G.player.knownLights.push(t); G.player.light = t; Stats.recalc(G.player); Log.add(`Footwork transforms: ${LIGHTNESS[t].name}.`, 'sys'); Game.removeItem(id, 1); UI.refresh(); } }, learnTechnique(tid, free) { if (G.player.techs[tid]) return; G.player.techs[tid] = 10; Log.add(`Learned ${TECHNIQUES[tid].name}!`, 'sys'); Sfx.play('levelup'); UI.refresh(); }, /* ---------------- learning from masters ---------------- */ teachListHas(npc, kind, id) { return (npc.teach || []).some(t => t[kind] === id); }, alreadyKnows(t) { if (t.tech) return !!G.player.techs[t.tech]; if (t.internal) return G.player.knownInternals.includes(t.internal); return false; }, canLearnFrom(npcId, t) { const n = NPCS[npcId]; const aff = G.aff[npcId] || 0; if (aff < (t.aff || 0)) return { ok: false, why: `needs affection ${t.aff}` }; if (t.reqRep && (G.rep[t.reqRep[0]] || 0) < t.reqRep[1]) return { ok: false, why: `needs ${t.reqRep[1]} ${t.reqRep[0]} rep` }; if (t.reqFlag && !G.flags[t.reqFlag]) return { ok: false, why: 'a secret must surface first' }; if (G.player.silver < (t.cost || 0)) return { ok: false, why: `${t.cost} silver` }; return { ok: true, why: '' }; }, learnFrom(npcId, t) { const ok = Game.canLearnFrom(npcId, t); if (!ok.ok) { Toast.show(ok.why); return; } if (!Game.spendSilver(t.cost || 0)) return; if (t.tech) { G.player.techs[t.tech] = 10; Log.add(`${Util.esc(NPCS[npcId].name)} teaches you ${TECHNIQUES[t.tech].name}.`, 'sys'); } if (t.internal) { G.player.knownInternals.push(t.internal); G.player.internal = t.internal; Log.add(`${Util.esc(NPCS[npcId].name)} transmits the ${INTERNALS[t.internal].name}.`, 'sys'); if (t.internal === 'i_sun') Game.unlockAch('sun_dawn'); } Game.addAffinity(npcId, 3); Sfx.play('levelup'); SaveSys.autosave(); UI.refresh(); }, learnHongArt() { G.player.techs['t_fist_4'] = 25; G.flags.learnedHong = true; Game.unlockAch('hong_heir'); Log.add('Hong presses his palms together \u2014 ten thousand rivers pour into your bones. Learned Ten Thousand Rivers Palms!', 'sys'); Sfx.play('levelup'); UI.refresh(); }, /* ---------------- affinity & gifts ---------------- */ addAffinity(npcId, n) { G.aff[npcId] = Util.clamp((G.aff[npcId] || 0) + n, 0, 100); UI.refreshTop && UI.refreshTop(); }, giveGift(npcId, itemId) { const it = DATA.ITEMS[itemId]; if (!Game.hasItem(itemId)) return; let val = (it.val || 2) + Math.floor(G.player.attrs.cha / 8); if (NPCS[npcId].giftTags && it.giftTags && it.giftTags.some(tg => NPCS[npcId].giftTags.includes(tg))) val += (it.likesVal || 3); const givenToday = (G.daily.giftsGiven[npcId] || 0); if (givenToday > 0) val = Math.ceil(val / (givenToday + 1)); G.daily.giftsGiven[npcId] = givenToday + 1; Game.removeItem(itemId, 1); Game.addAffinity(npcId, val); const reactions = [ [50, 'is visibly moved by the gift.'], [20, 'accepts with a warm smile.'], [0, 'thanks you politely.'] ]; const aff = G.aff[npcId]; const line = aff >= 80 ? 'treasures it like something irreplaceable.' : aff >= 50 ? reactions[0][1] : aff >= 20 ? reactions[1][1] : reactions[2][1]; Log.add(`${Util.esc(NPCS[npcId].name)} ${line} (${NPCS[npcId].name.split(' ')[0]} +${val} affection)`, 'good'); Sfx.play('coin'); UI.refresh(); }, useGiftOn(npcId, itemId) { Game.giveGift(npcId, itemId); }, addRep(fac, n) { G.rep[fac] = Util.clamp((G.rep[fac] || 0) + n, -100, 100); if (fac !== 'underground') G.rep.fame += Math.max(0, Math.round(n / 2)); else G.rep.infamy += Math.max(0, n); UI.refreshTop && UI.refreshTop(); }, addFame(n) { G.rep.fame += n; UI.refreshTop && UI.refreshTop(); }, addInfamy(n) { G.rep.infamy += n; UI.refreshTop && UI.refreshTop(); }, /* ---------------- achievements ---------------- */ unlockAch(id) { G.flags.ach = G.flags.ach || {}; if (!ACHIEVEMENTS[id] || G.flags.ach[id]) return; G.flags.ach[id] = true; Toast.show(`๐ Achievement: ${ACHIEVEMENTS[id].name}`); Log.add(`๐ Achievement unlocked: ${ACHIEVEMENTS[id].name}`, 'sys'); Sfx.play('levelup'); }, /* ---------------- EXP & levels ---------------- */ addExp(n) { const p = G.player; p.exp += n; Log.add(`Gained ${n} experience.`); let leveled = false; while (p.exp >= Stats.expNext(p.lvl) && p.lvl < 30) { p.exp -= Stats.expNext(p.lvl); p.lvl++; leveled = true; G.flags.pendingAttr = (G.flags.pendingAttr || 0) + 2; } if (leveled) { Stats.recalc(p); p.hp = p.d.maxHp; p.mp = p.d.maxMp; Log.add(`LEVEL UP! You are now level ${p.lvl}. (+2 attribute points)`, 'sys'); Sfx.play('levelup'); if (p.lvl >= 10) Game.unlockAch('journeyman'); if (p.lvl >= 20) Game.unlockAch('master_qi'); UI.openLevelUp && UI.openLevelUp(); } UI.refreshTop && UI.refreshTop(); }, spendAttrPoint(attr) { if ((G.flags.pendingAttr || 0) <= 0) return; G.player.attrs[attr]++; G.flags.pendingAttr--; Stats.recalc(G.player); UI.refresh(); if (!G.flags.pendingAttr) UI.closeModal && UI.closeModal(); }, /* ---------------- travel & encounters ---------------- */ travelTo(locId) { if (G.loc === locId) return; const edge = TRAVEL.find(e => (e[0] === G.loc && e[1] === locId) || (e[1] === G.loc && e[0] === locId)); if (!edge) { Toast.show('No road leads there directly.'); return; } const target = LOCS[locId]; if (target.nightOnly && !TimeSys.isNight()) { Toast.show('That place only opens after dusk.'); return; } if (target.hidden && !G.flags[target.hidden]) { Toast.show('You don\u2019t know how to get there yet.'); return; } const hours = edge[2]; TimeSys.advance(hours); G.loc = locId; G.discovered[locId] = true; Log.add(`Traveled to ${Util.esc(target.name)}. (${TimeSys.str()})`, 'sys'); SaveSys.autosave(); UI.setTab('scene'); Game.arriveEvents(locId); UI.refresh(); Game.rollEncounter(locId); }, arriveEvents(locId) { if (locId === 'l_market' && QSsafe('q_main_3') === 0) { Game.setStage('q_main_3', 1); } if (locId === 'l_market') Game.unlockAch('after_dark'); if (locId === 'l_city' && !G.flags.hongSeen && G.time.day >= 4 && Util.chance(0.5)) { G.flags.hongSeen = true; Toast.show('A drunk beggar sings badly by the wine shops\u2026'); } if (locId === 'l_mountain' && FVsafe('mainChapter') >= 4 && !G.flags.tombroute) { G.flags.tombroute = true; Toast.show('Behind the old shrine, buried stairs descend into darkness\u2026 (Ancient Tomb discovered)'); G.discovered['l_tomb'] = true; } if (locId === 'l_tomb' && QSsafe('q_main_5') === 0) Game.setStage('q_main_5', 1); }, rollEncounter(locId) { const L = LOCS[locId]; if (!L.danger) return; if (Util.chance(0.10 + L.danger * 0.08)) Game.wildEncounter(locId); }, wildEncounter(locId) { const L = LOCS[locId]; const plvl = G.player.lvl; const pool = plvl <= 4 ? (L.pools.low || L.pools.mid) : plvl <= 9 ? (L.pools.mid || L.pools.high) : (L.pools.high || L.pools.mid); if (!pool) return; const count = Util.chance(0.65) ? 2 : 1; const scale = 0.85 + Math.min(0.5, plvl * 0.045); const enemies = []; for (let i = 0; i < count; i++) enemies.push(Util.pick(pool)); Game.startBattle({ ids: enemies, scale, meta: { kind: 'wild', locName: L.name } }); }, /* ---------------- battles ---------------- */ startBattle(spec) { if (G.inBattle) return; const plvl = G.player.lvl; let scale = spec.scale || 1; if (G.flags.hardMode) scale *= 1.28; let enemies; if (spec.customEnemies && spec.customEnemies.length) { enemies = spec.customEnemies.map(u => { u.side = 'enemy'; return u; }); enemies.forEach(e => { e.expVal = e.expVal || 0; e.silver = null; }); } else { const ids = spec.ids || []; enemies = ids.map(id => makeEnemyUnit(id, scale)).filter(Boolean); enemies.forEach((e, i) => { e.enemyId = ids[i]; }); } if (!enemies.length) return; // bestiary tracking G.flags.seen = G.flags.seen || {}; enemies.forEach(e => { G.flags.seen[e.enemyId || e.name] = true; }); const allies = [makePlayerUnit(G.player)]; for (const cid of G.party.slice(0, 2)) { const cu = makeCompanionUnit(cid, plvl); if (cu) allies.push(cu); } const meta = Object.assign({ kind: 'wild' }, spec.meta || {}); if (meta.spar) { // remember pre-battle vitals to restore afterwards meta.preHp = G.player.hp; meta.preMp = G.player.mp; } G.battleMeta = meta; G.inBattle = true; const battle = new Battle({ allies, enemies, instant: !!spec.instant, spar: !!meta.spar, noDeath: !!meta.spar }); battle.on('log', (m) => Log.add(m, 'combat')); battle.on('end', (res) => Game.afterBattle(res, battle)); G.battle = battle; if (typeof document !== 'undefined' && UI.openBattle) UI.openBattle(battle); else battle.startRound(); return battle; }, registerKill(unit) { G.flags.totalKills = (G.flags.totalKills || 0) + 1; G.flags.kills = G.flags.kills || {}; const kid = unit.enemyId || unit.name; G.flags.kills[kid] = (G.flags.kills[kid] || 0) + 1; Game.unlockAch('first_blood'); const meta = G.battleMeta || {}; if (meta.kind === 'wolfhunt' && unit.enemyId === 'e_wolf') G.flags.wolfKills = (G.flags.wolfKills || 0) + 1; if (meta.kind === 'tomb' && unit.enemyId === 'e_sentinel') G.flags.sentinelKills = (G.flags.sentinelKills || 0) + 1; }, afterBattle(res, battle) { G._lastRes = res; const meta = G.battleMeta || {}; G.inBattle = false; const p = G.player; if (meta.spar) { // friendly: restore vitals, grant proficiency & affection p.hp = Math.min(meta.preHp, p.d.maxHp); p.mp = Math.min(meta.preMp, p.d.maxMp); if (res.win) { for (const tid of Object.keys(p.techs)) p.techs[tid] = Math.min(100, p.techs[tid] + 3); if (meta.npcId) { Game.addAffinity(meta.npcId, res.win ? 4 : 2); G.daily.sparred[meta.npcId] = true; } Log.add('A clean exchange! Techniques sharpen. (+3 proficiency, +4 affection)', 'good'); if (meta.npcId === 'n_beggar_hong') G.flags.hongBeats = (G.flags.hongBeats || 0) + 1; if (meta.npcId === 'n_monk_jing') { const qf = G.quests['q_trial_fl']; if (qf && !qf.done && qf.stage === 0 && !G.flags.trialFlSpar) { G.flags.trialFlSpar = true; Log.add('Monk Jing bows. \u201cYour staff speaks politely. Good.\u201d', 'sys'); Game.checkTrialFl(); } } if (meta.onWin) meta.onWin(); } else { if (meta.npcId) Game.addAffinity(meta.npcId, 2); Log.add('Defeated \u2014 but every bruise is a lesson.', 'bad'); } G.battleMeta = null; G.battle = null; UI.closeBattle && UI.closeBattle(); UI.refresh(); SaveSys.autosave(); return; } if (res.win) { let exp = 0, silver = 0; const loots = []; for (const e of battle.enemies) { exp += e.expVal || 20; if (e.silver) silver += Util.ri(e.silver[0], e.silver[1]); for (const l of (e.loot || [])) if (Util.chance(l.ch)) loots.push([l.id, l.q || 1]); } // luck bonus if (Util.chance(G.player.attrs.luk * 0.01)) { loots.push(['c_pill_hp', 1]); Log.add('Fortune smiles \u2014 extra loot!', 'good'); } Game.addSilver(silver); Game.addExp(exp); for (const [id, q] of loots) Game.addItem(id, q); // proficiency from techniques used for (const [tid, uses] of Object.entries(res.techUses || {})) { p.techs[tid] = Math.min(100, (p.techs[tid] || 0) + Math.min(6, uses * 2)); } if (meta.kind === 'wild') Game.addFame(1); } else { Game.defeat(meta); } // tear down battle state BEFORE onWin so chained battles open cleanly G.battleMeta = null; G.battle = null; G.inBattle = false; UI.closeBattle && UI.closeBattle(); if (res.win && meta.onWin) meta.onWin(); UI.refresh(); if (p.hp > 0) SaveSys.autosave(); }, defeat(meta) { const p = G.player; const loss = Math.round(p.silver * 0.1); p.silver -= loss; Log.add(`Darkness takes you\u2026 You awaken at an inn, lighter by ${loss} silver.`, 'bad'); Sfx.play('defeat'); p.hp = Math.max(1, Math.round(p.d.maxHp * 0.5)); p.mp = Math.max(0, Math.round(p.d.maxMp * 0.5)); // respawn at nearest safe town G.loc = ['l_village', 'l_city', 'l_town'].includes(G.loc) ? G.loc : 'l_city'; G.time.hour = 8; G.time.day += 1; Game.dailyReset(); }, /* ---------------- rest & cultivation ---------------- */ restInn(cost) { if (!Game.spendSilver(cost)) return; const p = G.player; p.hp = p.d.maxHp; p.mp = p.d.maxMp; const hoursToEight = ((24 - G.time.hour) + 8) % 24 || 24; TimeSys.advance(hoursToEight); Log.add('You sleep deeply and wake restored.', 'good'); SaveSys.autosave(); UI.closeModal && UI.closeModal(); UI.refresh(); }, campOut() { const L = LOCS[G.loc]; if (!L.danger) { Toast.show('Camp near town? Just sleep at the inn.'); return; } TimeSys.advance(6); const p = G.player; p.hp = Math.min(p.d.maxHp, p.hp + Math.round(p.d.maxHp * 0.55)); p.mp = Math.min(p.d.maxMp, p.mp + Math.round(p.d.maxMp * 0.55)); Log.add('You camp beneath cold stars.', 'sys'); if (Util.chance(0.18)) { Log.add('Something stalks the campfire light\u2026', 'bad'); Game.wildEncounter(G.loc); return; } UI.refresh(); }, meditate() { TimeSys.advance(2); const p = G.player; const gain = 8 + p.attrs.wit * 2 + (LOCS[G.loc].actions || []).some(a => a.id === 'meditate' && G.loc === 'l_temple' ? 4 : 0); p.internalExp += gain; Log.add(`You circulate qi quietly. (+${gain} internal insight)`); // temple trial progress const qf = G.quests['q_trial_fl']; if (qf && !qf.done && qf.stage === 0 && !G.flags.trialFlMed) { G.flags.trialFlMed = true; Log.add('The incense clock shows one full sitting. Patience, practiced.', 'sys'); Game.checkTrialFl(); } const need = p.internalLv * 55; if (p.internalExp >= need && p.internalLv < 9) { p.internalExp -= need; p.internalLv++; Stats.recalc(p); Log.add(`${UTIL_INT_NAME(p.internal)} reaches level ${p.internalLv}!`, 'sys'); Sfx.play('levelup'); } UI.refresh(); }, trainDummy() { if ((G.daily.dummyPts || 0) >= 15) { Toast.show('The posts have taught you all they can today.'); return; } UI.openTrainPick(); }, doTrainDummy(tid) { TimeSys.advance(1); const amt = Math.min(3, 15 - (G.daily.dummyPts || 0)); G.daily.dummyPts = (G.daily.dummyPts || 0) + amt; G.player.techs[tid] = Math.min(100, (G.player.techs[tid] || 0) + amt); Log.add(`Drilled ${TECHNIQUES[tid].name} against the posts. (+${amt} proficiency)`); UI.closeModal && UI.closeModal(); UI.refresh(); }, /* ---------------- location facilities ---------------- */ gatherHerbs() { const key = 'herb_' + G.loc; if (G.daily.gathered[key]) { Toast.show('Picked clean for today.'); return; } G.daily.gathered[key] = true; TimeSys.advance(1); const loc = G.loc; if (loc === 'l_valley') { Game.addItem('c_lotus', Util.ri(1, 2)); if (Util.chance(0.35)) Game.addItem('mat_gall', 1); if (Util.chance(0.2)) Game.addItem('c_herb_red', 1); } else if (loc === 'l_bamboo') { Game.addItem('c_herb_green', Util.ri(1, 2)); if (Util.chance(0.3)) Game.addItem('c_lotus', 1); } else { Game.addItem('c_herb_green', Util.ri(1, 2)); if (Util.chance(0.3)) Game.addItem('c_herb_red', 1); } UI.refresh(); }, prospectMine() { if (G.daily.mined) { Toast.show('The seam is picked over for today.'); return; } G.daily.mined = true; TimeSys.advance(2); G.flags.oreRuns = (G.flags.oreRuns || 0) + 1; Game.addItem('mat_ore_1', Util.ri(1, 2)); if (Util.chance(0.3 + G.player.attrs.luk * 0.01)) Game.addItem('mat_ore_2', 1); if (Util.chance(0.04 + G.player.attrs.luk * 0.004)) { Game.addItem('mat_ore_3', 1); Log.add('Star-metal glints in the rubble!', 'good'); } UI.refresh(); }, sectTasks() { if (!G.flags.azureMember) { Toast.show('Only Azure Cloud disciples may take missions.'); return; } const opts = [ { label: 'Patrol the cloud stairs (fight)', fn: () => { UI.closeModal(); Game.startBattle({ ids: ['e_bandit_vet'], scale: 1, meta: { kind: 'sect', onWin: () => { Game.addRep('azure', 8); Game.addSilver(40); } } }); } }, { label: 'Courier scrolls to the temple (2h)', fn: () => { TimeSys.advance(2); Game.addRep('azure', 6); Log.add('Scrolls delivered; the brothers nod approvingly. (+6 azure rep)', 'good'); UI.refresh(); } }, { label: 'Demonstrate forms for juniors (1h)', fn: () => { TimeSys.advance(1); Game.addRep('azure', 5); Game.addExp(20); UI.refresh(); } }, { label: 'Not today', fn: () => UI.closeModal() } ]; UI.modal('Mission Hall', 'A bored steward slides three wooden tallies across the desk.
', opts.map(o => ({ label: o.label, fn: o.fn }))); }, /* ---------------- economy facilities ---------------- */ buyItem(npcId, itemId) { const n = NPCS[npcId]; if (!n.shop) return; const it = DATA.ITEMS[itemId]; const price = Game.buyPrice(it); if (!Game.spendSilver(price)) return; Game.addItem(itemId, 1); UI.refreshModalIf && UI.refreshModalIf(); }, sellItem(itemId) { const it = DATA.ITEMS[itemId]; const price = Game.sellPrice(it); if (!Game.hasItem(itemId)) return; Game.removeItem(itemId, 1); Game.addSilver(price); UI.refreshModalIf && UI.refreshModalIf(); }, buyPrice(it) { const disc = Util.clamp(G.player.attrs.cha * 0.012, 0, 0.15) + ((G.rep.underground > 20 && LOCS[G.loc] === LOCS.l_market) ? 0.05 : 0); return Math.max(1, Math.round(it.price * (1 - disc))); }, sellPrice(it) { return Math.max(1, Math.floor(it.price * 0.42 * (1 + G.player.attrs.cha * 0.008))); }, smithUpgrade(fromId) { const up = SMITH_UPGRADES[fromId]; if (!up) return; if (!Game.hasItem(fromId) && !Object.values(G.player.equip).includes(fromId)) { Toast.show('You don\u2019t have that item.'); return; } for (const [m, q] of Object.entries(up.mats)) if (!Game.hasItem(m, q)) { Toast.show('Missing materials.'); return; } if (!Game.spendSilver(up.silver)) return; for (const [m, q] of Object.entries(up.mats)) Game.removeItem(m, q); // consume owned copy (equipped preferred) if (Object.values(G.player.equip).includes(fromId)) { for (const s of Object.keys(G.player.equip)) if (G.player.equip[s] === fromId) { G.player.equip[s] = null; break; } } else Game.removeItem(fromId, 1); Game.addItem(up.to, 1); Log.add(`The forge roars \u2014 crafted ${DATA.ITEMS[up.to].name}!`, 'sys'); Sfx.play('levelup'); Stats.recalc(G.player); UI.openSmith(); // refresh panel }, brew(recipeId) { const r = ALCHEMY_RECIPES.find(x => x.id === recipeId); if (!r) return; if (r.needFlag && !G.flags[r.needFlag]) { Toast.show('You don\u2019t know that recipe.'); return; } for (const [m, q] of Object.entries(r.mats)) if (!Game.hasItem(m, q)) { Toast.show('Missing ingredients.'); return; } if (!Game.spendSilver(r.silver)) return; for (const [m, q] of Object.entries(r.mats)) Game.removeItem(m, q); Game.addItem(r.out, r.qty); Log.add(`Brewed ${DATA.ITEMS[r.out].name} ร${r.qty}.`, 'good'); UI.openAlchemy(); }, /* ---------------- gambling & crime ---------------- */ gambleBet(kind, amount) { if (!Game.spendSilver(amount)) return null; const dice = [Util.ri(1, 6), Util.ri(1, 6), Util.ri(1, 6)]; const sum = dice[0] + dice[1] + dice[2]; const triple = dice[0] === dice[1] && dice[1] === dice[2]; let win = 0; if (kind === 'triple') { if (triple) win = amount * 28; } else if (kind === 'big') { if (!triple && sum >= 11) win = Math.floor(amount * 1.9); } else if (kind === 'small') { if (!triple && sum <= 10) win = Math.floor(amount * 1.9); } if (win) Game.addSilver(win); return { dice, sum, triple, win }; }, pickpocket() { if (G.daily.pickpocket) { Toast.show('Guards remember faces. Tomorrow, perhaps.'); return; } G.daily.pickpocket = true; TimeSys.advance(1); const p = G.player; const chance = Util.clamp(0.32 + p.attrs.agi * 0.03 + p.attrs.luk * 0.02 - (G.loc === 'l_market' ? 0.06 : 0), 0.15, 0.75); if (Util.chance(chance)) { const take = Util.ri(15, 70); Game.addSilver(take); Log.add('Light fingers, lighter purse. Nobody notices.', 'good'); } else { Game.addInfamy(2); const fine = Math.min(p.silver, 30); p.silver -= fine; Log.add(`Caught! A guard confiscates ${fine} silver and your dignity. (+2 infamy)`, 'bad'); } UI.refresh(); }, /* ---------------- fishing ---------------- */ /* ---------------- side jobs (job board postings) ---------------- */ SIDE_JOBS: { q_herb_bai: { give: () => `Deliver 5 Red Herb to Alchemist Bai\u2019s shelf.`, prog: () => `${Math.min(Game.countItem('c_herb_red'), 5)}/5 Red Herb`, ready: () => Game.hasItem('c_herb_red', 5), take: () => Game.removeItem('c_herb_red', 5), rw: { exp: 120, silver: 140, items: [['c_pill_hp', 2]], aff: [['n_alchemist_bai', 8]] } }, q_ring_reeds: { give: () => `Cull 4 serpents in Serpent Veil Valley; one swallowed a ring.`, prog: () => `${Math.min((G.flags.kills || {}).e_snake || 0, 4)}/4 serpents`, ready: () => ((G.flags.kills || {}).e_snake || 0) >= 4, take: () => {}, rw: { exp: 130, silver: 180, fame: 3, items: [['ac_rope', 1]] } }, q_forge_coal: { give: () => `Prospect ore twice at Cloudmist Ridge for Smith Wang.`, prog: () => `${Math.min(G.flags.oreRuns || 0, 2)}/2 expeditions`, ready: () => (G.flags.oreRuns || 0) >= 2, take: () => {}, rw: { exp: 110, silver: 160, items: [['mat_ore_1', 3]] } }, q_night_watch: { give: () => `Cull 3 bandits on the roads. Wage on delivery.`, prog: () => `${Math.min(((G.flags.kills || {}).e_bandit || 0) + ((G.flags.kills || {}).e_bandit_vet || 0), 3)}/3 bandits`, ready: () => (((G.flags.kills || {}).e_bandit || 0) + ((G.flags.kills || {}).e_bandit_vet || 0)) >= 3, take: () => {}, rw: { exp: 150, silver: 220, rep: [['garrison', 15]] } }, q_alms_lotus: { give: () => `Donate 300 silver to the temple restoration fund.`, prog: () => `300 silver required`, ready: () => G.player.silver >= 300, take: () => { G.player.silver -= 300; Log.add('You seal 300 silver into the temple fund box.', 'bad'); }, rw: { exp: 140, silver: 0, rep: [['temple', 20]] } }, q_lantern_dead: { give: () => `Bring 2 Mulled Wine and 1 Lotus Herb for the river rite.`, prog: () => `${Math.min(Game.countItem('c_wine'), 2)}/2 wine ยท ${Math.min(Game.countItem('c_lotus'), 1)}/1 lotus`, ready: () => Game.hasItem('c_wine', 2) && Game.hasItem('c_lotus', 1), take: () => { Game.removeItem('c_wine', 2); Game.removeItem('c_lotus', 1); }, rw: { exp: 100, silver: 90, fame: 5, items: [['c_antidote', 2]] } } }, sideState(qid) { const q = G.quests[qid]; if (!q) return 'offer'; return q.done ? 'done' : (((this.SIDE_JOBS[qid] || {}).ready && this.SIDE_JOBS[qid].ready()) ? 'ready' : 'active'); }, sideStart(qid) { if (G.quests[qid]) return; Game.startQuest(qid); Toast.show(`Job accepted: ${QUESTS[qid].name}`); }, sideDeliver(qid) { const job = this.SIDE_JOBS[qid]; const q = G.quests[qid]; if (!job || !q || q.done || !job.ready()) return; job.take(); Game.completeQuest(qid, {}); const rw = job.rw || {}; if (rw.exp) Game.addExp(rw.exp); if (rw.silver) Game.addSilver(rw.silver); if (rw.fame) Game.addFame(rw.fame); if (rw.rep) rw.rep.forEach(([f, n]) => Game.addRep(f, n)); if (rw.aff) rw.aff.forEach(([n, v]) => Game.addAffinity(n, v)); if (rw.items) rw.items.forEach(([i, n2]) => Game.addItem(i, n2)); Sfx.play('coin'); UI.refresh(); }, canFish() { return (G.daily.fished || 0) < 10; }, fishResult(hit) { G.daily.fished = (G.daily.fished || 0) + 1; G.flags.fishTotal = (G.flags.fishTotal || 0) + 1; if ((G.flags.fishTotal || 0) >= 25) Game.unlockAch('water_patience'); if (hit) { if (Util.chance(0.15)) { Game.addItem('c_fish', 2); Log.add('A double catch thrashes in the basket!', 'good'); } else Game.addItem('c_fish', 1); Sfx.play('coin'); } else if (Util.chance(0.5)) { Game.addItem('j_boot', 1); Log.add('You reel in\u2026 a boot. The river keeps its secrets.', 'bad'); } else { Log.add('The carp mocks your reflexes and departs with dignity.'); } }, /* ---------------- bounties ---------------- */ rollBounties() { const plvl = G.player.lvl; const tier = plvl <= 4 ? 1 : plvl <= 8 ? 2 : 3; const pool = BOUNTY_POOLS[tier]; const list = []; for (let i = 0; i < 3; i++) { const id = Util.pick(pool); const e = ENEMIES[id]; const cnt = e.tier <= 1 ? Util.ri(2, 3) : 1; const silver = Util.ri(30, 60) * e.tier * cnt; list.push({ id, count: cnt, silver, exp: Math.round((e.exp || 30) * 1.4 * cnt), fame: 4 * e.tier }); } G.bounty.list = list; G.bounty.taken = null; }, takeBounty(i) { if (G.bounty.taken !== null) { Toast.show('Finish your current contract first.'); return; } G.bounty.taken = i; Toast.show('Contract accepted. Hunt them via the board.'); UI.refresh(); }, huntBounty() { const b = G.bounty.list[G.bounty.taken]; if (!b) return; const scale = 0.9 + G.player.lvl * 0.03; const ids = []; for (let i = 0; i < b.count; i++) ids.push(b.id); Game.startBattle({ ids, scale, meta: { kind: 'bounty', onWin: () => { Game.addSilver(b.silver); Game.addExp(b.exp); Game.addFame(b.fame); G.bounty.list.splice(G.bounty.taken, 1); if (!G.bounty.list.length) Game.rollBounties(); G.bounty.taken = null; Log.add('Bounty claimed. Justice, monetized.', 'sys'); } } }); }, /* ---------------- tournament ---------------- */ isTournamentDay() { return G.time.day % 7 === 5; }, enterTournament() { if (!Game.isTournamentDay()) return; G.tourney.lastDay = G.time.day; G.tourney.stage = 0; TimeSys.advance(1); Game.nextTourneyFight(); }, nextTourneyFight() { const st = G.tourney.stage || 0; if (st >= TOURNEY_LADDER.length) { Toast.show('You have already conquered this week\u2019s bracket.'); return; } const rung = TOURNEY_LADDER[st]; const scale = 0.9 + G.player.lvl * 0.05; Game.startBattle({ ids: [rung.base], scale, meta: { kind: 'arena', name: rung.name, onWin: () => Game.tourneyWin() } }); }, tourneyWin() { G.tourney.stage = (G.tourney.stage || 0) + 1; const p = G.player; p.hp = Math.min(p.d.maxHp, p.hp + Math.round(p.d.maxHp * 0.45)); p.mp = Math.min(p.d.maxMp, p.mp + Math.round(p.d.maxMp * 0.45)); const prize = [80, 150, 250, 500][G.tourney.stage - 1] || 100; Game.addSilver(prize); Game.addFame(6); if (G.tourney.stage >= TOURNEY_LADDER.length) { Game.addFame(20); G.flags.champion = true; Game.unlockAch('champion'); Log.add('CHAMPION! The crowd roars your name across Qingyun!', 'sys'); Sfx.play('victory'); if (QSsafe('q_main_3') === 2) { Game.setStage('q_main_3', 3); Toast.show('Host Qian waves you over urgently\u2026'); } } else { Log.add(`Victory in round ${G.tourney.stage}! Prize: ${prize} silver.`, 'good'); UI.modal('Tournament', `Round ${G.tourney.stage} won! Your next opponent awaits: ${TOURNEY_LADDER[G.tourney.stage].name}.
`, [{ label: 'Fight on', fn: () => { UI.closeModal(); Game.nextTourneyFight(); } }, { label: 'Withdraw (keep winnings)', fn: () => UI.closeModal() }]); } }, dukangAmbush() { UI.modal('The Pavilion', 'Behind the arena, a man waits beneath a silver-paper umbrella. Du Kang smiles without warmth.
\u201cThe celebrated ridge-clearer! The Society offers glory, wealth\u2026 and long life. Refuse, and become an anecdote.\u201d
\u201cWise. The Ledger in the old tomb \u2014 bring it to us, and names, ranks, silver follow. Fail, and\u2026 well.\u201d
You could play along\u2026