Exact source used by this build. SHA-256: f92d401a1d1417e190aef365652fc79e2796955e6344e076eee5912175320d8b.
1 import { recordVillagerQuestProgress } from "../villagers";
2 import type { MineMonsterDefinition } from "../../../../content/src/domains/mines/schema";
3 import { createRng, type SeededRng } from "../../rng";
4 import { withPlayer } from "../../state";
5 import type { Area, Facing, IntentResult, Player, SimEvent, SimRules, TilePosition, WorldState } from "../../types";
6 import { addGroundDrop, getInventory, takeSlot, type ItemStack } from "../inventory";
7 import { getItem } from "../items";
8 import { isBlocked } from "../movement";
9 import { grantXp } from "../skills";
10 import { debitMoney, walletBalance } from "../shipping/money";
11 import { getMineContent, getMineEquipmentEffects, getMineFloor, getMinePlayer, getMines, mineDay, setMineFloor, setMinePlayer, type MineFloor, type MineMonster, type MinesContent } from "./model";
12
13 export function lineOfSight(area: Area, from: TilePosition, to: TilePosition, ignoreDestinationTile = false): boolean {
14 const steps = Math.max(1, Math.ceil(Math.hypot(to.x - from.x, to.y - from.y) * 8));
15 for (let step = 1; step < steps; step++) { const x = from.x + (to.x - from.x) * step / steps, y = from.y + (to.y - from.y) * step / steps; if (ignoreDestinationTile && Math.floor(x) === Math.floor(to.x) && Math.floor(y) === Math.floor(to.y)) continue; if (isBlocked(area, x, y)) return false; }
16 return true;
17 }
18 export function slidePosition(area: Area, origin: TilePosition, dx: number, dy: number, flying = false): TilePosition {
19 let x = origin.x, y = origin.y;
20 const blocked = (px: number, py: number) => px < 1 || py < 1 || px >= area.width - 1 || py >= area.height - 1 || (flying ? area.collision[Math.floor(py) * area.width + Math.floor(px)] === true : isBlocked(area, px, py));
21 const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy) * 8));
22 for (let index = 0; index < steps; index++) { if (!blocked(x + dx / steps, y)) x += dx / steps; if (!blocked(x, y + dy / steps)) y += dy / steps; }
23 return { x, y };
24 }
25 export function rollMineDrops(state: WorldState, areaId: string, position: TilePosition, definitions: { itemId: string; min: number; max: number; chance: number }[], rng: SeededRng, onDrop?: (itemId: string, quantity: number) => void): WorldState {
26 for (const drop of definitions) if (rng.next() < drop.chance) { const quantity = drop.min + rng.nextInt(drop.max - drop.min + 1); state = addGroundDrop(state, areaId, position.x, position.y, { id: drop.itemId, quantity, quality: "regular" }); onDrop?.(drop.itemId, quantity); }
27 return state;
28 }
29 export function mineKnockout(state: WorldState, playerId: string, rules: SimRules, rng: SeededRng): IntentResult {
30 const data = getMineContent(rules), original = state.players[playerId];
31 if (!data || !original) return { state, events: [] };
32 const mine = getMinePlayer(original), config = data.config.knockout, balance = walletBalance(state, playerId);
33 const rate = config.minGoldRate + rng.next() * (config.maxGoldRate - config.minGoldRate), goldLost = Math.min(config.maxGoldLoss, Math.floor(balance * rate));
34 let player = original;
35 const items: ItemStack[] = [], returnedTools = [...mine.returnedTools], candidates = getInventory(player, rules).slots.map((stack, index) => ({ stack, index })).filter((entry) => entry.stack !== null);
36 for (let index = candidates.length - 1; index > 0; index--) { const other = rng.nextInt(index + 1); [candidates[index], candidates[other]] = [candidates[other]!, candidates[index]!]; }
37 for (const { stack, index } of candidates) {
38 if (items.length >= config.maxLostStacks) break;
39 if (!stack || rng.next() >= config.itemLossChance) continue;
40 const taken = takeSlot(player, index, stack.quantity, rules); if (!taken) continue;
41 player = taken.player; items.push(taken.stack);
42 if (getItem(rules, stack.id)?.category === "tool") returnedTools.push(taken.stack);
43 }
44 player = setMinePlayer({ ...player, areaId: state.starterPlayer.areaId, x: state.starterPlayer.x, y: state.starterPlayer.y, energy: Math.floor(player.maxEnergy / 2), sleeping: false, sleepConfirmedAt: null }, { ...mine, health: config.healthRestored, invulnerableUntilMs: getMines(state).timeMs + data.config.invulnerabilityMs, blockUntilMs: 0, returnedTools, lastKnockout: { goldLost, items, day: mineDay(state) } });
45 const paid = debitMoney(state, playerId, goldLost)!;
46 return { state: withPlayer(paid, player), events: [{ type: "mineKnockout", playerId, data: { goldLost, items, rate } }, { type: "areaChanged", playerId, areaId: player.areaId, snapshot: true }] };
47 }
48 function hitPlayer(state: WorldState, playerId: string, damage: number, source: TilePosition, rules: SimRules, rng: SeededRng, now: number): IntentResult {
49 const data = getMineContent(rules)!, player = state.players[playerId]!, mine = getMinePlayer(player), area = state.areas[player.areaId]!;
50 if (mine.invulnerableUntilMs > now) return { state, events: [] };
51 if (mine.blockUntilMs > now) return { state, events: [{ type: "mineBlocked", playerId, areaId: player.areaId, transient: true }] };
52 const amount = Math.max(1, damage - getMineEquipmentEffects(player, rules).defense), health = Math.max(0, mine.health - amount), distance = Math.hypot(player.x - source.x, player.y - source.y) || 1;
53 const position = slidePosition(area, player, (player.x - source.x) / distance * data.config.playerKnockback, (player.y - source.y) / distance * data.config.playerKnockback);
54 const next = withPlayer(state, setMinePlayer({ ...player, ...position }, { ...mine, health, invulnerableUntilMs: now + data.config.invulnerabilityMs }));
55 const event: SimEvent = { type: "mineDamage", playerId, areaId: player.areaId, transient: true, data: { x: player.x, y: player.y, amount, targetId: playerId, critical: false } };
56 if (health > 0) return { state: next, events: [event] };
57 const result = mineKnockout(next, playerId, rules, rng); return { state: result.state, events: [event, ...result.events] };
58 }
59 const vector = (facing: Facing) => facing === "up" ? { x: 0, y: -1 } : facing === "down" ? { x: 0, y: 1 } : facing === "left" ? { x: -1, y: 0 } : { x: 1, y: 0 };
60 export function inAttackArc(player: TilePosition, target: TilePosition, facing: Facing, reach: number, arcDegrees: number): boolean {
61 const dx = target.x - player.x, dy = target.y - player.y, distance = Math.hypot(dx, dy);
62 if (distance > reach) return false;
63 if (distance < .01 || arcDegrees >= 360) return true;
64 const forward = vector(facing); return (dx * forward.x + dy * forward.y) / distance >= Math.cos(arcDegrees * Math.PI / 360) - 1e-9;
65 }
66 export function attackMine(state: WorldState, player: Player, rules: SimRules, facing: Facing, special: boolean): IntentResult {
67 const data = getMineContent(rules), floor = getMineFloor(state, player.areaId), area = state.areas[player.areaId], mine = getMinePlayer(player), now = getMines(state).timeMs;
68 const weapon = data?.weapons.find((entry) => entry.itemId === mine.equipment.weapon);
69 const reject = (error: string): IntentResult => ({ state, events: [], error });
70 if (!data || !floor || !area) return reject("Enter the mine first");
71 if (!weapon) return reject("Equip a weapon");
72 if (now < mine.attackReadyAtMs || special && now < mine.specialReadyAtMs) return reject("Weapon is recovering");
73 const effects = getMineEquipmentEffects(player, rules), reach = weapon.reach * (special ? weapon.special.reachMultiplier : 1), arcDegrees = special && weapon.special.kind === "slam" ? 360 : weapon.arcDegrees;
74 const updated: typeof mine = { ...mine, attackReadyAtMs: now + weapon.swingMs, specialReadyAtMs: special ? now + weapon.specialCooldownMs : mine.specialReadyAtMs, blockUntilMs: special && weapon.special.kind === "block" ? now + weapon.special.durationMs : mine.blockUntilMs };
75 let next = withPlayer(state, setMinePlayer({ ...player, facing }, updated));
76 const events: SimEvent[] = [{ type: "mineSwing", playerId: player.id, areaId: area.id, transient: true, data: { x: player.x, y: player.y, facing, reach, arcDegrees, special, weaponId: weapon.id } }];
77 if (special && weapon.special.kind === "block") return { state: next, events };
78 const rng = createRng(floor.rngState), monsters = { ...floor.monsters };
79 let ladder = floor.ladder;
80 for (const original of Object.values(monsters)) {
81 if (!inAttackArc(player, original, facing, reach, arcDegrees) || !lineOfSight(area, player, original)) continue;
82 const definition = data.monsters.find((entry) => entry.id === original.monsterId)!;
83 let monster = original;
84 const hits = special ? weapon.special.hits : 1;
85 for (let hit = 0; hit < hits && monster.health > 0; hit++) {
86 const critical = rng.next() < weapon.critChance, roll = weapon.minDamage + rng.nextInt(weapon.maxDamage - weapon.minDamage + 1), multiplier = (1 + effects.attack) * (special ? weapon.special.damageMultiplier : 1) * (critical ? weapon.critMultiplier : 1);
87 // Armored enemies resist frontal attacks; pickaxes and attacks from behind remain effective.
88 const facingPlayer = monster.facingX * (player.x - monster.x) + monster.facingY * (player.y - monster.y) > 0;
89 const defense = definition.defense * (definition.behavior === "armored" && facingPlayer ? 2 : 1);
90 const amount = Math.max(1, Math.floor(roll * multiplier - defense));
91 monster = { ...monster, health: Math.max(0, monster.health - amount) };
92 events.push({ type: "mineDamage", playerId: player.id, areaId: area.id, transient: true, data: { x: monster.x, y: monster.y, amount, targetId: monster.id, critical } });
93 }
94 if (monster.health <= 0) {
95 const drops: { itemId: string; quantity: number }[] = [];
96 delete monsters[monster.id]; next = rollMineDrops(next, area.id, monster, definition.drops, rng, (itemId, quantity) => drops.push({ itemId, quantity }));
97 let earned = grantXp(next.players[player.id]!, "combat", definition.xp, rules);
98 if (effects.lifeSteal > 0) { const health = getMinePlayer(earned); earned = setMinePlayer(earned, { ...health, health: Math.min(health.maxHealth, health.health + effects.lifeSteal) }); }
99 next = recordVillagerQuestProgress(withPlayer(next, earned), player.id, { kind: "slay", quantity: 1, monsterTags: ["monster", definition.behavior, definition.id] }, rules);
100 events.push({ type: "mineDefeated", playerId: player.id, areaId: area.id, data: { monsterId: monster.id, definitionId: definition.id, xp: definition.xp, drops } });
101 if (!ladder && floor.number < data.config.maxFloor && floor.kind !== "infested" && rng.next() < data.config.ladder.killChance) ladder = { x: Math.floor(monster.x), y: Math.floor(monster.y) };
102 } else {
103 const distance = Math.hypot(monster.x - player.x, monster.y - player.y) || 1, knockback = weapon.knockback * (1 - definition.knockbackResistance);
104 monsters[monster.id] = { ...monster, ...slidePosition(area, monster, (monster.x - player.x) / distance * knockback, (monster.y - player.y) / distance * knockback, definition.behavior === "flying"), stunnedUntilMs: now + data.config.hitStunMs };
105 }
106 }
107 if (!ladder && floor.kind === "infested" && Object.keys(monsters).length === 0 && floor.number < data.config.maxFloor) ladder = { x: Math.floor(player.x), y: Math.floor(player.y) };
108 if (ladder && !floor.ladder) events.push({ type: "mineLadder", areaId: area.id, data: { ...ladder } });
109 next = setMineFloor(next, next.areas[area.id]!, { ...floor, monsters, ladder, rngState: rng.state });
110 return { state: next, events };
111 }
112 function pursuitTarget(area: Area, monster: MineMonster, player: Player, flying: boolean): TilePosition {
113 if (lineOfSight(area, monster, player)) return player;
114 const start = Math.floor(monster.y) * area.width + Math.floor(monster.x), goal = Math.floor(player.y) * area.width + Math.floor(player.x);
115 const queue = [start], parents = new Map<number, number>([[start, start]]);
116 for (let at = 0; at < queue.length && !parents.has(goal); at++) {
117 const current = queue[at]!, x = current % area.width, y = Math.floor(current / area.width);
118 for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
119 const px = x + dx, py = y + dy, key = py * area.width + px;
120 if (px <= 0 || py <= 0 || px >= area.width - 1 || py >= area.height - 1 || parents.has(key) || area.collision[key] || !flying && area.tiles[key]?.objectId) continue;
121 parents.set(key, current); queue.push(key);
122 }
123 }
124 if (!parents.has(goal)) return player;
125 let step = goal;
126 while (parents.get(step) !== start && step !== start) step = parents.get(step)!;
127 return { x: step % area.width + .5, y: Math.floor(step / area.width) + .5 };
128 }
129 function moveMonster(area: Area, monster: MineMonster, definition: MineMonsterDefinition, target: Player, deltaMs: number, now: number, contactRange: number): MineMonster {
130 const destination = pursuitTarget(area, monster, target, definition.behavior === "flying"), dx = destination.x - monster.x, dy = destination.y - monster.y, distance = Math.hypot(dx, dy);
131 if (Math.hypot(target.x - monster.x, target.y - monster.y) <= contactRange * .75 || distance < .001 || monster.stunnedUntilMs > now) return monster;
132 const direction = { x: dx / distance, y: dy / distance }, moving = { ...monster, facingX: direction.x, facingY: direction.y };
133 if (definition.behavior === "ranged" && Math.hypot(target.x - monster.x, target.y - monster.y) <= definition.range && lineOfSight(area, monster, target)) return moving;
134 if (definition.behavior === "slimeHop") {
135 if (monster.hopReadyAtMs > now) return moving;
136 return { ...moving, ...slidePosition(area, monster, direction.x * Math.min(distance, definition.speed), direction.y * Math.min(distance, definition.speed)), hopReadyAtMs: now + definition.hopMs };
137 }
138 const amount = Math.min(distance, definition.speed * deltaMs / 1000);
139 return { ...moving, ...slidePosition(area, monster, direction.x * amount, direction.y * amount, definition.behavior === "flying") };
140 }
141 /** Fixed small substeps prevent tunneling and make long alarm intervals safe. */
142 export function simulateMineFloor(state: WorldState, areaId: string, rules: SimRules, elapsedMs: number, startMs: number): IntentResult {
143 const data = getMineContent(rules)!; let next = state; const events: SimEvent[] = [];
144 for (let offset = 0; offset < elapsedMs; offset += 100) {
145 const delta = Math.min(100, elapsedMs - offset), now = startMs + offset + delta, area = next.areas[areaId], floor = getMineFloor(next, areaId);
146 if (!area || !floor) break;
147 const players = () => Object.values(next.players).filter((player) => player.connected && !player.sleeping && player.areaId === areaId);
148 if (players().length === 0) break;
149 const rng = createRng(floor.rngState), monsters = { ...floor.monsters }, projectiles = [...floor.projectiles]; let nextProjectileId = floor.nextProjectileId;
150 for (const original of Object.values(monsters)) {
151 const target = players().sort((a, b) => Math.hypot(a.x - original.x, a.y - original.y) - Math.hypot(b.x - original.x, b.y - original.y) || a.id.localeCompare(b.id))[0]; if (!target) break;
152 const definition = data.monsters.find((entry) => entry.id === original.monsterId)!;
153 let monster = moveMonster(area, original, definition, target, delta, now, data.config.monsterContactRange);
154 const distance = Math.hypot(target.x - monster.x, target.y - monster.y);
155 if (monster.attackReadyAtMs <= now && monster.stunnedUntilMs <= now) {
156 if (distance <= data.config.monsterContactRange) {
157 const result = hitPlayer(next, target.id, definition.damage, monster, rules, rng, now); next = result.state; events.push(...result.events); monster = { ...monster, attackReadyAtMs: now + definition.attackMs };
158 } else if (definition.behavior === "ranged" && distance <= definition.range && lineOfSight(area, monster, target)) {
159 projectiles.push({ id: `projectile_${nextProjectileId++}`, ownerId: monster.id, x: monster.x, y: monster.y, vx: (target.x - monster.x) / distance * definition.projectileSpeed, vy: (target.y - monster.y) / distance * definition.projectileSpeed, damage: definition.damage, expiresAtMs: now + data.config.projectileLifetimeMs });
160 monster = { ...monster, attackReadyAtMs: now + definition.attackMs };
161 }
162 }
163 monsters[monster.id] = monster;
164 }
165 const remaining = [] as MineFloor["projectiles"];
166 for (const projectile of projectiles) {
167 if (projectile.expiresAtMs <= now) continue;
168 const position = { x: projectile.x + projectile.vx * delta / 1000, y: projectile.y + projectile.vy * delta / 1000 };
169 if (isBlocked(area, position.x, position.y) || !lineOfSight(area, projectile, position)) continue;
170 const hit = players().find((player) => Math.hypot(player.x - position.x, player.y - position.y) < .65);
171 if (hit) { const result = hitPlayer(next, hit.id, projectile.damage, projectile, rules, rng, now); next = result.state; events.push(...result.events); }
172 else remaining.push({ ...projectile, ...position });
173 }
174 next = setMineFloor(next, next.areas[areaId]!, { ...floor, monsters, projectiles: remaining, nextProjectileId, rngState: rng.state });
175 }
176 return { state: next, events };
177 }
178