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