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 .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
cache: "npm"

- name: Install dependencies
run: npm ci
run: npm install

- name: Type check
run: npm run check
Expand Down
5 changes: 5 additions & 0 deletions client/src/lib/game/gameUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const clamp = (n: number, a: number, b: number) => Math.max(a, Math.min(b, n));
export const rand = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
export const uid = () => Math.random().toString(36).slice(2, 9);
export const fmt = (n: number) => new Intl.NumberFormat().format(Math.floor(n));
export const pick = <T,>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)];
55 changes: 55 additions & 0 deletions client/src/lib/game/gateSystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { rand, uid } from "@/lib/game/gameUtils";

export const RANKS = ["E", "D", "C", "B", "A", "S"] as const;
export const RANK_COLORS = { E: "bg-green-600", D: "bg-blue-600", C: "bg-purple-600", B: "bg-red-600", A: "bg-orange-600", S: "bg-yellow-600" };
export interface Boss { name: string; maxHp: number; hp: number; atk: number; def: number; }
export interface DungeonModifier { id: string; name: string; description: string; icon: string; type: "buff" | "debuff" | "neutral"; applyToRewards: (expMult: number, goldMult: number) => { expMult: number; goldMult: number }; applyToCombat: (playerDmgMult: number, bossDmgMult: number) => { playerDmgMult: number; bossDmgMult: number }; }
export interface Gate { id: string; name: string; rank: string; rankIdx: number; recommended: number; power: number; boss: Boss; modifiers: DungeonModifier[]; }
export interface Item { id: string; name: string; type: string; rarity: "common" | "uncommon" | "rare" | "epic" | "legendary"; quality: number; description?: string; stats?: Record<string, number>; equipmentSlot?: "weapon" | "armor" | "accessory" | null; sellValue?: number; }

export const DUNGEON_MODIFIERS: DungeonModifier[] = [
{ id: "double_exp", name: "Double EXP", description: "2× EXP reward from this gate", icon: "📚", type: "buff", applyToRewards: (e, g) => ({ expMult: e * 2, goldMult: g }), applyToCombat: (p, b) => ({ playerDmgMult: p, bossDmgMult: b }) },
{ id: "treasure_vault", name: "Treasure Vault", description: "2× gold reward", icon: "💰", type: "buff", applyToRewards: (e, g) => ({ expMult: e, goldMult: g * 2 }), applyToCombat: (p, b) => ({ playerDmgMult: p, bossDmgMult: b }) },
{ id: "empowered_boss", name: "Empowered Boss", description: "Boss deals 40% more damage", icon: "💀", type: "debuff", applyToRewards: (e, g) => ({ expMult: e, goldMult: g }), applyToCombat: (p, b) => ({ playerDmgMult: p, bossDmgMult: b * 1.4 }) },
{ id: "cursed_ground", name: "Cursed Ground", description: "You deal 25% less damage", icon: "☠️", type: "debuff", applyToRewards: (e, g) => ({ expMult: e, goldMult: g }), applyToCombat: (p, b) => ({ playerDmgMult: p * 0.75, bossDmgMult: b }) },
{ id: "heroic", name: "Heroic", description: "Boss is stronger, but rewards are doubled", icon: "⚔️", type: "neutral", applyToRewards: (e, g) => ({ expMult: e * 2, goldMult: g * 2 }), applyToCombat: (p, b) => ({ playerDmgMult: p, bossDmgMult: b * 1.5 }) },
{ id: "weakened_boss", name: "Weakened Boss", description: "Boss deals 30% less damage", icon: "🤕", type: "buff", applyToRewards: (e, g) => ({ expMult: e, goldMult: g }), applyToCombat: (p, b) => ({ playerDmgMult: p, bossDmgMult: b * 0.7 }) },
];
const MONSTER_DATA = {
E: { name: "Goblin Warrior" }, D: { name: "Orc Berserker" }, C: { name: "Dark Elf Assassin" },
B: { name: "Troll Chieftain" }, A: { name: "Dragon Knight" }, S: { name: "Void Lord" },
} as const;
const GATE_NAMES: Record<string, string[]> = {
E: ["Goblin Burrow", "Mushroom Grotto", "Rat Warren", "Slime Pit", "Mossy Tunnel", "Abandoned Mine", "Shallow Cave", "Dusty Cellar"],
D: ["Orc Stronghold", "Cursed Mines", "Swamp Depths", "Iron Crypt", "Bandit Hideout", "Troll Bridge", "Dark Hollow", "Bone Quarry"],
C: ["Shadow Forest", "Moonlit Ruins", "Phantom Keep", "Crimson Marsh", "Spider Nest", "Haunted Chapel", "Witch's Glade", "Twilight Gorge"],
B: ["Troll Citadel", "Thunder Peak", "Frozen Fortress", "Magma Cavern", "War Bastion", "Storm Spire", "Obsidian Vault", "Siege Grounds"],
A: ["Dragon's Lair", "Inferno Sanctum", "Sky Fortress", "Ashen Throne", "Blazing Halls", "Wyrm's Den", "Phoenix Roost", "Flame Citadel"],
S: ["Void Nexus", "Abyssal Gate", "Reality Fracture", "Chaos Rift", "World's Edge", "Dimensional Tear", "Oblivion Core", "Shattered Plane"],
};

export function gatePowerForRank(rankIdx: number) { return Math.round(Math.pow(1.7, rankIdx) * 30 + rankIdx * 20); }
export function makeBoss(rankIdx: number): Boss {
const base = gatePowerForRank(rankIdx); const rank = RANKS[rankIdx]; const hp = Math.floor(base * 8 + rand(-25, 25));
return { name: MONSTER_DATA[rank].name, maxHp: hp, hp, atk: Math.floor(base * 0.8 + rand(-5, 5)), def: Math.floor(base * 0.3 + rand(-3, 3)) };
}
export function makeGate(rankIdx: number, usedNames?: Set<string>): Gate {
const rank = RANKS[rankIdx]; const namePool = GATE_NAMES[rank] ?? GATE_NAMES.E; const unused = namePool.filter((n) => !usedNames?.has(n)); const source = unused.length ? unused : namePool;
const modifierCount = Math.random() < 0.25 + rankIdx * 0.08 ? (Math.random() < 0.35 ? 2 : 1) : 0;
return { id: uid(), name: source[Math.floor(Math.random() * source.length)], rank, rankIdx, recommended: gatePowerForRank(rankIdx), power: Math.round(Math.max(10, gatePowerForRank(rankIdx) + rand(-20, 20))), boss: makeBoss(rankIdx), modifiers: [...DUNGEON_MODIFIERS].sort(() => Math.random() - 0.5).slice(0, modifierCount) };
}
export function generateGatePool(playerLevel: number): Gate[] {
const gates: Gate[] = []; const used = new Set<string>(); const add = (rankIdx: number) => { const gate = makeGate(rankIdx, used); used.add(gate.name); gates.push(gate); };
for (let i = 0; i < rand(2, 4); i++) add(0); if (playerLevel >= 3) for (let i = 0; i < rand(2, 4); i++) add(1); if (playerLevel >= 6) for (let i = 0; i < rand(2, 4); i++) add(2); if (playerLevel >= 10) for (let i = 0; i < rand(2, 4); i++) add(3); if (playerLevel >= 15) for (let i = 0; i < rand(2, 4); i++) add(4); if (playerLevel >= 18) for (let i = 0; i < rand(1, 2); i++) add(5); if (playerLevel >= 25) { for (let i = 0; i < rand(1, 2); i++) add(4); for (let i = 0; i < rand(1, 2); i++) add(5); }
return gates;
}
export function rollDrop(gate: Gate): Item | null {
const rarityRoll = (rankIdx: number) => { const r = Math.random(); return rankIdx >= 4 && r < 0.05 ? "legendary" : rankIdx >= 3 && r < 0.15 ? "epic" : rankIdx >= 2 && r < 0.35 ? "rare" : rankIdx >= 1 && r < 0.65 ? "uncommon" : "common"; };
const qualityFor = (rarity: string) => Math.max(1, Math.min(100, ({ common: 30, uncommon: 50, rare: 70, epic: 85, legendary: 95 } as Record<string, number>)[rarity] + rand(-10, 10)));
const r = Math.random();
if (r < 0.08) { const rarity = rarityRoll(gate.rankIdx); const quality = qualityFor(rarity); return { id: uid(), name: "Instant Dungeon Key", type: "key", rarity, quality, description: "Opens a special dungeon with enhanced rewards", sellValue: Math.floor(quality * 2) }; }
if (r < 0.28) { const statType = ["STR", "AGI", "INT", "VIT", "LUCK"][rand(0, 4)]; const rarity = rarityRoll(gate.rankIdx); const quality = qualityFor(rarity); const statBonus = Math.max(1, Math.floor((quality / 100) * (gate.rankIdx + 1) * 2)); return { id: uid(), name: `${gate.rank}-grade ${statType} Rune`, type: "rune", rarity, quality, description: `Temporarily boosts ${statType} by ${statBonus}`, stats: { [statType]: statBonus }, sellValue: Math.floor(quality * 1.5) }; }
if (r < 0.55) { const rarity = rarityRoll(gate.rankIdx); const quality = qualityFor(rarity); const healAmount = Math.floor((quality / 100) * 50 + 25 + gate.rankIdx * 15); return { id: uid(), name: `${gate.rank}-grade Potion`, type: "potion", rarity, quality, description: `Restores ${healAmount} HP`, stats: { HP: healAmount }, sellValue: Math.floor(quality * 1.2) }; }
if (r < 0.65) { const rarity = rarityRoll(gate.rankIdx); const quality = qualityFor(rarity); const slot = ["weapon", "armor", "accessory"][rand(0, 2)] as "weapon" | "armor" | "accessory"; const statMap = { weapon: "STR", armor: "VIT", accessory: "LUCK" } as const; const itemName = { weapon: `${gate.rank}-Rank Blade`, armor: `${gate.rank}-Rank Armor`, accessory: `${gate.rank}-Rank Charm` }[slot]; const statBonus = Math.max(1, Math.floor((quality / 100) * (gate.rankIdx + 1) * 3)); return { id: uid(), name: itemName, type: "equipment", rarity, quality, description: `Provides ${statBonus} ${statMap[slot]}`, stats: { [statMap[slot]]: statBonus }, equipmentSlot: slot, sellValue: Math.floor(quality * 3) }; }
return null;
}
30 changes: 30 additions & 0 deletions client/src/lib/game/questSystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { uid } from "@/lib/game/gameUtils";

export interface DailyTask { id: string; name: string; description: string; type: "combat" | "exploration" | "collection" | "skill" | "challenge"; difficulty: "easy" | "medium" | "hard" | "epic"; need: number; have: number; expReward: number; goldReward: number; bonusRewards?: { items?: Array<{ id: string; name: string; type: string; rarity: "common" | "uncommon" | "rare" | "epic" | "legendary"; quality: number; description?: string; sellValue?: number }>; statPoints?: number } }
export interface GameTime { day: number; currentDate: string; lastReset: string; }

export function getCurrentGameDate() { const now = new Date(); return `${String(now.getMonth() + 1).padStart(2, "0")}/${String(now.getDate()).padStart(2, "0")}`; }
export function initialGameTime(): GameTime { const currentDate = getCurrentGameDate(); return { day: 1, currentDate, lastReset: currentDate }; }
export function formatGameTime(gameTime: GameTime) { return `Day ${gameTime.day} - ${gameTime.currentDate}`; }
export function getDifficultyColor(difficulty: string) { return ({ easy: "text-green-400", medium: "text-yellow-400", hard: "text-orange-400", epic: "text-purple-400" } as Record<string, string>)[difficulty] ?? "text-gray-400"; }
export function getDifficultyBgColor(difficulty: string) { return ({ easy: "bg-green-600", medium: "bg-yellow-600", hard: "bg-orange-600", epic: "bg-purple-600" } as Record<string, string>)[difficulty] ?? "bg-gray-600"; }

export function generateDailyQuests(playerLevel: number, questReputation = 0): DailyTask[] {
const quests: DailyTask[] = []; const difficulties: DailyTask["difficulty"][] = ["easy"]; if (playerLevel >= 5) difficulties.push("medium"); if (playerLevel >= 10) difficulties.push("hard"); if (playerLevel >= 15 && questReputation >= 50) difficulties.push("epic");
const defs = [
{ type: "combat" as const, name: "Monster Hunter", description: "Defeat monsters in gates", scale: 3, epic: 5 },
{ type: "exploration" as const, name: "Gate Explorer", description: "Enter gates of different ranks", scale: 5, epic: 4 },
{ type: "collection" as const, name: "Item Collector", description: "Gather items from gates", scale: 4, epic: 4 },
{ type: "skill" as const, name: "Resource Manager", description: "Use potions and runes effectively", scale: 6, epic: 4 },
{ type: "challenge" as const, name: "Perfect Hunter", description: "Complete gates without taking damage", scale: 8, epic: 4 },
];
const mult: Record<DailyTask["difficulty"], number> = { easy: 1, medium: 2, hard: 3, epic: 4 };
const rewardMult: Record<DailyTask["difficulty"], number> = { easy: 0.8, medium: 1.2, hard: 1.8, epic: 2.5 };
const used = new Set<string>();
for (let i = 0; i < 5; i++) {
const available = defs.filter((d) => !used.has(d.type)); const quest = (available.length ? available : defs)[Math.floor(Math.random() * (available.length ? available.length : defs.length))]; used.add(quest.type);
const difficulty = difficulties[Math.floor(Math.random() * difficulties.length)]; const base = Math.max(1, Math.floor(playerLevel / quest.scale)); const need = difficulty === "epic" ? base * quest.epic : base * mult[difficulty];
quests.push({ id: `${quest.type}_${difficulty}_${i}`, name: `${quest.name} (${difficulty.charAt(0).toUpperCase() + difficulty.slice(1)})`, description: `${quest.description} - ${need} times`, type: quest.type, difficulty, need, have: 0, expReward: Math.floor(playerLevel * 10 * rewardMult[difficulty]), goldReward: Math.floor(playerLevel * 5 * rewardMult[difficulty]), bonusRewards: difficulty === "epic" ? { items: [{ id: uid(), name: "Epic Quest Reward", type: "potion", rarity: "rare", quality: 80, description: "Special reward for completing an epic quest", sellValue: 100 }], statPoints: 1 } : undefined });
}
return quests;
}
41 changes: 41 additions & 0 deletions client/src/lib/game/spiritSystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { rand, uid } from "@/lib/game/gameUtils";

export type SpiritRarity = "common" | "uncommon" | "rare" | "epic" | "legendary";
export type SpiritType = "warrior" | "mage" | "rogue" | "tank" | "support";
export interface SpiritAbility { id: string; name: string; description: string; type: "passive" | "active"; effect: string; cooldown?: number; }
export interface Spirit { id: string; name: string; power: number; rarity: SpiritRarity; abilities: SpiritAbility[]; level: number; exp: number; expToNext: number; type: SpiritType; description: string; }

export const SPIRIT_TYPES: SpiritType[] = ["warrior", "mage", "rogue", "tank", "support"];
export const SPIRIT_ABILITIES: Record<SpiritType, SpiritAbility[]> = {
warrior: [{ id: "berserker_rage", name: "Berserker Rage", description: "Increases damage by 25% when below 50% HP", type: "passive", effect: "damage_boost" }, { id: "cleave", name: "Cleave", description: "Attacks all enemies in range", type: "active", effect: "aoe_attack", cooldown: 3 }],
mage: [{ id: "arcane_mastery", name: "Arcane Mastery", description: "Spells have 15% chance to cast twice", type: "passive", effect: "double_cast" }, { id: "mana_shield", name: "Mana Shield", description: "Absorbs damage using MP instead of HP", type: "active", effect: "damage_absorption", cooldown: 5 }],
rogue: [{ id: "shadow_step", name: "Shadow Step", description: "Every 3rd attack is enhanced (+10% damage)", type: "passive", effect: "damage_boost" }, { id: "poison_blade", name: "Poison Blade", description: "Attacks poison enemies for 3 turns", type: "passive", effect: "poison_damage" }],
tank: [{ id: "fortress", name: "Fortress", description: "Reduces all damage taken by 30%", type: "passive", effect: "damage_reduction" }, { id: "ethereal_shield", name: "Ethereal Shield", description: "15% chance to block boss attacks entirely", type: "passive", effect: "block_chance" }, { id: "taunt", name: "Taunt", description: "Forces enemies to attack this spirit", type: "active", effect: "force_aggro", cooldown: 2 }],
support: [{ id: "healing_aura", name: "Healing Aura", description: "Heals all allies for 10% of max HP each turn", type: "passive", effect: "heal_aura" }, { id: "vitality_aura", name: "Vitality Aura", description: "Restores 2 HP per combat tick", type: "passive", effect: "hp_regen" }, { id: "blessing", name: "Blessing", description: "Increases all ally stats by 20% for 3 turns", type: "active", effect: "stat_boost", cooldown: 6 }],
};
export const SPIRIT_DESCRIPTIONS: Record<SpiritType, string> = {
warrior: "A fierce combatant specializing in melee damage and aggressive tactics.",
mage: "A master of arcane arts, wielding powerful spells and magical abilities.",
rogue: "A stealthy assassin with high critical hit chance and poison attacks.",
tank: "A stalwart defender who protects allies and absorbs enemy attacks.",
support: "A benevolent ally who heals and enhances the capabilities of the team.",
};
const rarityMultipliers: Record<SpiritRarity, number> = { common: 1, uncommon: 1.2, rare: 1.5, epic: 2, legendary: 3 };
const spiritNames = ["Umbra", "Noctis", "Tenebris", "Kage", "Silens", "Vorago", "Ater", "Nox", "Moria", "Caecus"];

export function getRarityFromBossRank(bossRankIdx: number): SpiritRarity {
const roll = Math.random();
if (bossRankIdx >= 4) return roll < 0.05 ? "legendary" : roll < 0.15 ? "epic" : roll < 0.35 ? "rare" : roll < 0.65 ? "uncommon" : "common";
if (bossRankIdx >= 2) return roll < 0.02 ? "epic" : roll < 0.08 ? "rare" : roll < 0.25 ? "uncommon" : "common";
return roll < 0.01 ? "rare" : roll < 0.1 ? "uncommon" : "common";
}

export function createSpirit(gatePower: number, bossRankIdx: number): Spirit {
const type = SPIRIT_TYPES[rand(0, SPIRIT_TYPES.length - 1)];
const rarity = getRarityFromBossRank(bossRankIdx);
const abilities = SPIRIT_ABILITIES[type].slice(0, Math.min(rarity === "common" ? 1 : rarity === "epic" ? 3 : rarity === "legendary" ? 4 : 2, SPIRIT_ABILITIES[type].length));
return { id: uid(), name: `${spiritNames[rand(0, spiritNames.length - 1)]}-${rand(1, 999)}`, power: Math.floor(gatePower * 0.8 * rarityMultipliers[rarity]), rarity, abilities, level: 1, exp: 0, expToNext: 100, type, description: SPIRIT_DESCRIPTIONS[type] };
}

export function getRarityColor(rarity: SpiritRarity) { return ({ common: "text-gray-400", uncommon: "text-green-400", rare: "text-blue-400", epic: "text-purple-400", legendary: "text-yellow-400" })[rarity]; }
export function getRarityBorder(rarity: SpiritRarity) { return ({ common: "border-gray-500", uncommon: "border-green-500", rare: "border-blue-500", epic: "border-purple-500", legendary: "border-yellow-500" })[rarity]; }
Loading
Loading