Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion js/data/backgrounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const BACKGROUNDS = [
startingGold: 25,
startingItems: [
{ id: 'short_bow', qty: 1 },
{ id: 'arrow', qty: 20 },
{ id: 'sling', qty: 1 },
{ id: 'hunting_knife', qty: 1 },
{ id: 'travel_rations', qty: 3 },
],
Expand Down
251 changes: 170 additions & 81 deletions js/data/items.ts

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions js/data/monsters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const MONSTERS = [
def: 1,
xp: 20,
gold: [1, 8],
loot: [{ id: 'crude_dagger', chance: 15 }, { id: 'stolen_coin', chance: 30 }],
loot: [{ id: 'crude_dagger', chance: 15 }, { id: 'stolen_coin', chance: 30 }, { id: 'bone_knife', chance: 10 }, { id: 'lucky_charm', chance: 5 }],
abilities: [{ id: 'flee', name: 'Flee', desc: 'Runs away when below 25% HP.', chance: 0 }],
description: 'A small, cunning creature with a talent for ambush.',
flavorText: ['The goblin cackles. "Your gold or your life!"', '"Stupid adventurer! Skreek will get you!"'],
Expand All @@ -78,7 +78,7 @@ export const MONSTERS = [
def: 2,
xp: 32,
gold: [5, 20],
loot: [{ id: 'short_sword', chance: 10 }, { id: 'leather_armor', chance: 8 }],
loot: [{ id: 'short_sword', chance: 10 }, { id: 'leather_armor', chance: 8 }, { id: 'leather_cap', chance: 20 }, { id: 'hand_axe', chance: 12 }],
abilities: [{ id: 'trip', name: 'Trip', desc: 'Target loses next turn.', chance: 15 }],
description: 'A desperate criminal who\'d rather take your valuables than earn them.',
flavorText: ['"Hand over your coin and we\'ll let you live."', '"Nothing personal. Just business."'],
Expand Down Expand Up @@ -128,7 +128,7 @@ export const MONSTERS = [
def: 3,
xp: 50,
gold: [5, 25],
loot: [{ id: 'iron_axe', chance: 12 }, { id: 'chain_mail', chance: 8 }],
loot: [{ id: 'iron_axe', chance: 12 }, { id: 'chain_mail', chance: 8 }, { id: 'orcish_hide', chance: 25 }, { id: 'war_hammer', chance: 8 }],
abilities: [{ id: 'battle_cry', name: 'Battle Cry', desc: '+3 attack for 2 turns.', chance: 20 }],
description: 'A brutal warrior driven by bloodlust and tribal loyalty.',
flavorText: ['"Waaagh!" it bellows.', 'The orc raises its axe with a terrifying roar.'],
Expand All @@ -144,7 +144,7 @@ export const MONSTERS = [
def: 3,
xp: 55,
gold: [0, 5],
loot: [{ id: 'bear_pelt', chance: 50 }, { id: 'bear_claw', chance: 40 }],
loot: [{ id: 'bear_pelt', chance: 50 }, { id: 'bear_claw', chance: 40 }, { id: 'hide_armor', chance: 20 }],
abilities: [{ id: 'maul', name: 'Maul', desc: 'Double damage.', chance: 15 }],
description: 'A massive brown bear, territorial and short-tempered.',
flavorText: ['The bear rears up on its hind legs.', 'An enormous roar shakes the ground.'],
Expand All @@ -160,7 +160,7 @@ export const MONSTERS = [
def: 4,
xp: 80,
gold: [3, 15],
loot: [{ id: 'troll_hide', chance: 30 }],
loot: [{ id: 'troll_hide', chance: 30 }, { id: 'horned_helm', chance: 15 }],
abilities: [{ id: 'regenerate', name: 'Regenerate', desc: 'Heals 3 HP at the start of each turn.', chance: 100 }],
description: 'A hulking, dim-witted brute that heals at an alarming rate. Fire and acid stop the regeneration.',
flavorText: ['The troll\'s wounds knit closed before your eyes.', '"Troll smell meat!"'],
Expand All @@ -176,7 +176,7 @@ export const MONSTERS = [
def: 3,
xp: 70,
gold: [10, 40],
loot: [{ id: 'dark_elf_blade', chance: 10 }, { id: 'mana_potion', chance: 25 }],
loot: [{ id: 'dark_elf_blade', chance: 10 }, { id: 'mana_potion', chance: 25 }, { id: 'elven_mail', chance: 10 }, { id: 'elven_talisman', chance: 5 }],
abilities: [
{ id: 'shadow_step', name: 'Shadow Step', desc: 'Evades one attack.', chance: 20 },
{ id: 'poison_blade', name: 'Poison Blade', desc: 'Attack poisons target.', chance: 25 },
Expand All @@ -197,7 +197,7 @@ export const MONSTERS = [
def: 5,
xp: 150,
gold: [10, 50],
loot: [{ id: 'ogre_club', chance: 15 }, { id: 'large_gem', chance: 8 }],
loot: [{ id: 'ogre_club', chance: 15 }, { id: 'large_gem', chance: 8 }, { id: 'horned_helm', chance: 12 }, { id: 'iron_cap', chance: 20 }],
abilities: [{ id: 'smash', name: 'Smash', desc: 'Deal 3x damage, stun for 1 turn.', chance: 20 }],
description: 'A massive humanoid that can shatter rock with its fists.',
flavorText: ['"SMASH!"', 'The ground trembles with each lumbering step.'],
Expand All @@ -213,7 +213,7 @@ export const MONSTERS = [
def: 2,
xp: 130,
gold: [5, 20],
loot: [{ id: 'ectoplasm', chance: 40 }, { id: 'cursed_amulet', chance: 5 }],
loot: [{ id: 'ectoplasm', chance: 40 }, { id: 'cursed_amulet', chance: 5 }, { id: 'death_mask', chance: 8 }, { id: 'cursed_ring', chance: 6 }],
abilities: [
{ id: 'drain', name: 'Life Drain', desc: 'Steal 5 HP from target.', chance: 30 },
{ id: 'phase', name: 'Phase Shift', desc: '30% chance to dodge all attacks.', chance: 100 },
Expand All @@ -234,7 +234,7 @@ export const MONSTERS = [
def: 8,
xp: 500,
gold: [100, 300],
loot: [{ id: 'dragon_scale', chance: 70 }, { id: 'dragon_tooth', chance: 50 }],
loot: [{ id: 'dragon_scale', chance: 70 }, { id: 'dragon_tooth', chance: 50 }, { id: 'dragon_helm', chance: 30 }],
abilities: [
{ id: 'fire_breath', name: 'Fire Breath', desc: 'Deal 3d8 fire damage to all.', chance: 25 },
{ id: 'tail_sweep', name: 'Tail Sweep', desc: 'Knock back and stun.', chance: 15 },
Expand Down
63 changes: 56 additions & 7 deletions js/systems/combat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function spawnEncounter(biome, dangerLevel, rng) {
// ─── Combat State ────────────────────────────────────────────────────────────

export function initCombat(player, monsters, biome, rng) {
return {
const combat = {
state: COMBAT_STATE.PLAYER_TURN,
player,
monsters,
Expand All @@ -71,6 +71,29 @@ export function initCombat(player, monsters, biome, rng) {
// For multi-enemy, current target
targetIdx: 0,
};

// Tracking: +3 initiative — 30% chance to surprise enemies and skip their first attack
if (player.skills && player.skills.includes('tracking')) {
const surpriseChance = 30 + Math.max(0, (player.stats.dex - 10));
if (rng.chance(surpriseChance)) {
combat.log.push({ msg: 'Your tracking skills let you spot the enemy first — they are surprised!', type: 'system' });
combat._enemySurprised = true;
}
}

// Archery firstStrikeChance: 30% chance to get the jump on enemies with a ranged weapon
if (!combat._enemySurprised && player.skills && player.skills.includes('archery')) {
const weapon = player.equipment?.weapon;
const wItem = weapon ? player.inventory.find(i => i.id === weapon) : null;
// Check if equipped weapon has ranged prop via item data (import may not be available here;
// use a simple check — archery applies even without item data)
if (rng.chance(30)) {
combat.log.push({ msg: 'Archery training: you draw and fire before the enemy can react!', type: 'system' });
combat._enemySurprised = true;
}
}

return combat;
}

// Get active monster (first alive)
Expand Down Expand Up @@ -301,9 +324,26 @@ export function playerUseAbility(combat, abilityId) {
return advanceToEnemyTurn(combat);
}

case 'forage':
logMsg(combat, 'Cannot forage during combat!', 'system');
return; // Don't advance turn
case 'forage': {
if (combat._forageUsed) {
logMsg(combat, 'You have already foraged in this area.', 'system');
return;
}
combat._forageUsed = true;
// Herbalism passive: find 1-3 healing herbs based on skill and DEX
const herbCount = 1 + (hasSkill(player, 'herbalism') ? rng.int(0, 2) : 0);
let added = 0;
for (let h = 0; h < herbCount; h++) {
if (player.inventory.length < 20) {
const existing = player.inventory.find(i => i.id === 'healing_herb');
if (existing) { existing.qty += 1; }
else { player.inventory.push({ id: 'healing_herb', qty: 1 }); }
added++;
}
}
logMsg(combat, added > 0 ? `You forage the area and find ${added} Healing Herb${added > 1 ? 's' : ''}.` : 'Your inventory is full — no room for herbs.', 'system');
return; // Forage does not consume a combat turn
}

case 'backstab':
logMsg(combat, 'Backstab triggers automatically on the first turn.', 'system');
Expand Down Expand Up @@ -394,6 +434,13 @@ function advanceToEnemyTurn(combat) {
const monster = getActiveMonster(combat);
if (!monster) return checkVictory(combat);

// Tracking/Archery surprise: skip enemy's very first attack
if (combat._enemySurprised && combat.turn === 1) {
combat._enemySurprised = false;
combat.state = COMBAT_STATE.PLAYER_TURN;
return;
}

// Process status effects first
processMonsterStatus(combat, monster);
if (combat.state !== COMBAT_STATE.ENEMY_TURN) return;
Expand Down Expand Up @@ -630,15 +677,17 @@ function checkVictory(combat) {
// Calculate total rewards
combat.totalXp = combat.monsters.reduce((sum, m) => sum + m.xp, 0);
combat.totalGold = combat.monsters.reduce((sum, m) => sum + m.gold, 0);
combat.lootItems = collectLoot(combat.monsters, combat.rng);
combat.lootItems = collectLoot(combat.monsters, combat.player, combat.rng);
}
}

function collectLoot(monsters, rng) {
function collectLoot(monsters, player, rng) {
const items = [];
// Lockpicking passive: +15% chance on each drop
const lootMult = hasSkill(player, 'lockpicking') ? 1.15 : 1.0;
for (const m of monsters) {
for (const drop of (m.loot || [])) {
if (rng.chance(drop.chance)) {
if (rng.chance(Math.min(100, drop.chance * lootMult))) {
items.push(drop.id);
}
}
Expand Down
1 change: 1 addition & 0 deletions js/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ export interface Item {
desc: string;
tier: number;
prop?: string;
region?: string; // 'mountain' | 'forest' | 'desert' | 'swamp' | 'plains' | 'arctic' | 'coastal'
cursed?: boolean;
effect?: string;
heal?: number;
Expand Down
34 changes: 24 additions & 10 deletions js/ui/screens/shopScreen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export class ShopScreen {

const tier = this.npc?.shopTier || 1;
const role = this.npc?.shopRole || 'general';
this.shopItems = getShopInventoryByRole(role, tier);
const region = this.npc?.shopRegion || null;
this.shopItems = getShopInventoryByRole(role, tier, region);

this.buyList.setItems(this.shopItems, 14);
this.buyList.onSelect = () => {};
Expand All @@ -50,15 +51,28 @@ export class ShopScreen {
return Object.values(eq).includes(invItem.id);
}

_hasBargaining() {
return this.game.player?.skills?.includes('bargaining');
}

_buyPrice(item) {
return this._hasBargaining() ? Math.floor(item.value * 0.85) : item.value;
}

_sellPrice(item) {
return Math.max(1, Math.floor(item.value * (this._hasBargaining() ? 0.65 : 0.5)));
}

_requestBuy() {
const player = this.game.player;
if (!player) return;

const item = this.shopItems[this.buyList.selected];
if (!item) return;

if (player.gold < item.value) {
this.message = `Not enough gold! Need ${item.value}g.`;
const buyPrice = this._buyPrice(item);
if (player.gold < buyPrice) {
this.message = `Not enough gold! Need ${buyPrice}g.`;
this.messageColor = C.RED;
return;
}
Expand All @@ -79,7 +93,7 @@ export class ShopScreen {
}

this._confirm = new Confirm(
`Buy ${item.name} for ${item.value}g?`,
`Buy ${item.name} for ${buyPrice}g?`,
() => { this._executeBuy(item); this._confirm = null; },
() => { this._confirm = null; }
);
Expand All @@ -88,7 +102,7 @@ export class ShopScreen {
_executeBuy(item) {
const player = this.game.player;
if (!player) return;
player.gold -= item.value;
player.gold -= this._buyPrice(item);

if (item.id === 'local_map') {
const loc = this.game.currentLocation;
Expand All @@ -106,7 +120,7 @@ export class ShopScreen {

addToInventory(player, item.id, 1);
this._refreshSellList();
this.message = `Bought ${item.name} for ${item.value}g.`;
this.message = `Bought ${item.name} for ${this._buyPrice(item)}g.`;
this.messageColor = C.GREEN;
this.game.addMessage(`Bought ${item.name}.`, 'normal');
}
Expand All @@ -131,7 +145,7 @@ export class ShopScreen {
return;
}

const price = Math.max(1, Math.floor(item.value * 0.5));
const price = this._sellPrice(item);
const label = sellableQty > 1 ? `Sell 1x ${item.name} for ${price}g?` : `Sell ${item.name} for ${price}g?`;
this._confirm = new Confirm(
label,
Expand Down Expand Up @@ -280,7 +294,7 @@ export class ShopScreen {
const equipped = this.mode === 'sell' && this._isEquipped(entry);
const sellableQty = this.mode === 'sell' ? (entry.qty - (equipped ? 1 : 0)) : null;
const unavailable = this.mode === 'sell' && equipped && sellableQty <= 0;
const price = this.mode === 'buy' ? item.value : Math.max(1, Math.floor(item.value * 0.5));
const price = this.mode === 'buy' ? this._buyPrice(item) : this._sellPrice(item);
const priceStr = unavailable ? ' (eq)' : `${price}g`.padStart(6);
const qtyStr = this.mode === 'sell' && entry.qty > 1 ? ` x${sellableQty}` : '';
const nameWidth = width - 11 - qtyStr.length;
Expand All @@ -307,8 +321,8 @@ export class ShopScreen {
if (item.mp) renderer.write(42, dr++, `MP: +${item.mp}`, C.CYAN, C.BLACK);
dr++;

const buyPrice = item.value;
const sellPrice = Math.max(1, Math.floor(item.value * 0.5));
const buyPrice = this._buyPrice(item);
const sellPrice = this._sellPrice(item);
renderer.write(42, dr++, `Buy: ${buyPrice}g`, C.WHITE, C.BLACK);
renderer.write(42, dr++, `Sell: ${sellPrice}g`, C.YELLOW, C.BLACK);
dr++;
Expand Down
5 changes: 5 additions & 0 deletions js/world/towngen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ function buildingNPCs(rng, building, loc) {
id: `bar_${building.x}`, name: barName,
type: 'barkeep', pool: 'barkeep',
isShopkeeper: true, shopRole: 'tavern', shopTier: tier,
shopRegion: loc.region || null,
shopLabel: 'The Tavern Bar',
});
npcs.push({ id: `vil_${building.x}`, name: rng.pick(NPC_NAMES.villager), type: 'villager', pool: 'villager' });
Expand All @@ -578,6 +579,7 @@ function buildingNPCs(rng, building, loc) {
id: `shop_${building.x}`, name: rng.pick(NPC_NAMES.shopkeeper),
type: 'shopkeeper', pool: 'shopkeeper',
isShopkeeper: true, shopRole: 'general', shopTier: tier,
shopRegion: loc.region || null,
shopLabel: 'General Store',
});
break;
Expand All @@ -586,6 +588,7 @@ function buildingNPCs(rng, building, loc) {
id: `heal_${building.x}`, name: rng.pick(NPC_NAMES.healer),
type: 'healer', pool: 'healer',
isShopkeeper: true, shopRole: 'healer', shopTier: tier,
shopRegion: loc.region || null,
shopLabel: 'Healer\'s Supplies',
});
break;
Expand All @@ -594,6 +597,7 @@ function buildingNPCs(rng, building, loc) {
id: `temp_${building.x}`, name: rng.pick(NPC_NAMES.healer),
type: 'healer', pool: 'healer',
isShopkeeper: true, shopRole: 'healer', shopTier: tier,
shopRegion: loc.region || null,
shopLabel: 'Temple Stores',
});
break;
Expand All @@ -616,6 +620,7 @@ function buildingNPCs(rng, building, loc) {
id: `smith_${building.x}`, name: rng.pick(NPC_NAMES.shopkeeper),
type: 'shopkeeper', pool: 'shopkeeper',
isShopkeeper: true, shopRole: 'blacksmith', shopTier: tier,
shopRegion: loc.region || null,
shopLabel: 'Blacksmith\'s Forge',
});
break;
Expand Down
18 changes: 18 additions & 0 deletions js/world/worldgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ import { NPC_NAMES, INN_NAMES } from '../data/dialog';
import { generateTown } from './towngen';
import { generateDungeon } from './dungeogen';

// Map world biome tile IDs to regional flavour strings used for shop inventory filtering
function biomeToRegion(biomeId) {
const map = {
7: 'mountain', // MOUNTAINS
8: 'mountain', // PEAK
6: 'mountain', // HILLS
4: 'forest', // FOREST
5: 'forest', // DENSE_FOREST
9: 'desert', // DESERT
12: 'swamp', // SWAMP
10: 'arctic', // TUNDRA
11: 'arctic', // SNOW
2: 'coastal', // BEACH
};
return map[biomeId] || 'plains';
}

// Simple value noise for terrain generation
function valueNoise(rng, w, h, scale) {
const grid = new Float32Array(w * h);
Expand Down Expand Up @@ -209,6 +226,7 @@ export function generateWorld(seed) {
if (type === LOC_TYPE.TOWN) {
loc.tier = Math.floor(rng.float(1, 3.5));
loc.innName = rng.pick(INN_NAMES);
loc.region = biomeToRegion(tiles[y * W + x]);
loc.layout = null; // generated lazily
} else {
loc.dangerLevel = rng.int(1, 4);
Expand Down
Loading