Open Route 42 to Mahogany, the Lake of Rage, the Rocket Hideout and Pryce (#32)
Route 42, Mt. Mortar, Mahogany Town, Route 43 and its gates, the Lake of Rage and its houses, the Rocket Hideout and Route 44 are open. The story plays through the toll, the red Gyarados, Lance, the hideout and Pryce's GLACIERBADGE, which starts the Radio Tower takeover. The fishing guru measures Magikarp (CheckMagikarpLength, MagikarpHouseSign, FindPartyMonThatSpecies); the record is saved. takemoney now leaves a short account at zero as TakeMoney does, and setmapscene/setscene on a map without scenes consume the byte instead of refusing. The playthrough's path search slides on ice and the run gains five stages to Pryce. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
62fe4bf664
commit
061ca4d59d
11 files changed
+578
-43
No files matched your search
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { magikarpLength } from "../src/game/battle/battle";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("Mahogany and the Lake of Rage with the local ROM", () => {
|
||||
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||
|
||||
const MAGIKARP_HOUSE = "9:2",
|
||||
ROUTE_43 = "9:5",
|
||||
ROUTE_43_GATE = "9:4",
|
||||
LAKE_OF_RAGE = "9:6";
|
||||
const MAGIKARP = 129,
|
||||
TYPHLOSION = 157,
|
||||
ELIXER = 0x41;
|
||||
const EVENT_CLEARED_ROCKET_HIDEOUT = 0x22,
|
||||
EVENT_LAKE_OF_RAGE_EXPLAINED_WEIRD_MAGIKARP = 0x38,
|
||||
EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP = 0x39,
|
||||
EVENT_LAKE_OF_RAGE_ELIXIR_ON_STANDBY = 0x3a,
|
||||
EVENT_ROUTE_43_GATE_ROCKETS = 0x6dc;
|
||||
|
||||
const world = () => {
|
||||
let seed = 13;
|
||||
return new GameWorld(
|
||||
new CrystalAssets(bytes),
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
() => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
},
|
||||
);
|
||||
};
|
||||
const mon = (
|
||||
game: GameWorld,
|
||||
species: number,
|
||||
{ dvs = 0x8888, otId = game.playerId, ot = "KAWA", egg = false } = {},
|
||||
) =>
|
||||
Object.assign(
|
||||
generatePartyMon(game.assets.pokemon, {
|
||||
species,
|
||||
level: 20,
|
||||
item: 0,
|
||||
otId,
|
||||
ot,
|
||||
dvs,
|
||||
caughtData: 0,
|
||||
}),
|
||||
egg ? { egg } : {},
|
||||
);
|
||||
/** Enter `map`, stand at (x, y) facing `face`, press A and answer. */
|
||||
const talk = (
|
||||
game: GameWorld,
|
||||
map: string,
|
||||
x: number,
|
||||
y: number,
|
||||
face: "up" | "down" | "left" | "right",
|
||||
answers: number[] = [],
|
||||
) => {
|
||||
game.warpTo(map, x, y);
|
||||
settle(game);
|
||||
// The guru spins in place; keep him where his script expects.
|
||||
game.restore({ ...game.save(), x, y, direction: face, objects: [] });
|
||||
game.interact();
|
||||
const shown = settle(game, answers).join(" ");
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
return shown;
|
||||
};
|
||||
/** The fishing guru stands at (2, 3); talk to him from below. */
|
||||
const guru = (game: GameWorld, answers: number[] = []) =>
|
||||
talk(game, MAGIKARP_HOUSE, 2, 4, "up", answers);
|
||||
/** The record sign outside his house: only its name until the Rocket
|
||||
* Hideout is cleared. */
|
||||
const sign = (game: GameWorld) => talk(game, LAKE_OF_RAGE, 25, 32, "up");
|
||||
/** DVs whose Magikarp, for this OT ID, measures longer or shorter than
|
||||
* the starting 3′6″ record. */
|
||||
const dvsFor = (game: GameWorld, otId: number, longer: boolean) => {
|
||||
for (let dvs = 0; dvs < 0x10000; dvs += 0x0101) {
|
||||
const { feet, inches } = magikarpLength(game.assets.reader, dvs, otId);
|
||||
if (longer ? feet * 12 + inches > 42 : feet * 12 + inches < 42)
|
||||
return { dvs, length: `${feet}′${inches}″` };
|
||||
}
|
||||
throw new Error("No such Magikarp.");
|
||||
};
|
||||
|
||||
it("measures a longer Magikarp as the new record, with its OT as the holder", () => {
|
||||
const game = world();
|
||||
expect(sign(game)).not.toContain("RECORD");
|
||||
game.state.events.add(EVENT_CLEARED_ROCKET_HIDEOUT);
|
||||
expect(sign(game)).toContain("3′6″ caught by\nRALPH");
|
||||
game.state.events.add(EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP);
|
||||
const otId = 4321;
|
||||
const { dvs, length } = dvsFor(game, otId, true);
|
||||
game.state.party = [
|
||||
mon(game, TYPHLOSION),
|
||||
mon(game, MAGIKARP, { dvs, otId, ot: "ZOE" }),
|
||||
];
|
||||
const shown = guru(game, [1]);
|
||||
expect(shown).toContain(`it measures\n${length}.`);
|
||||
expect(shown).toContain("Take this as a\nmemento!");
|
||||
expect(game.state.bag.items).toContainEqual({ item: ELIXER, quantity: 1 });
|
||||
// The holder is the Magikarp's own OT, not the player.
|
||||
expect(sign(game)).toContain(`${length} caught by\nZOE`);
|
||||
// The record is saved and survives a reload.
|
||||
const saved = JSON.parse(JSON.stringify(game.save()));
|
||||
expect(saved.magikarpRecord).toMatchObject({ holder: "ZOE" });
|
||||
const reloaded = world();
|
||||
reloaded.restore(saved);
|
||||
expect(sign(reloaded)).toContain(`${length} caught by\nZOE`);
|
||||
// The same length again is not longer.
|
||||
expect(guru(game, [1])).toContain("seen a bigger one");
|
||||
});
|
||||
|
||||
it("turns away a shorter Magikarp, another Pokémon, a refusal and an Egg", () => {
|
||||
const game = world();
|
||||
game.state.events.add(EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP);
|
||||
const { dvs } = dvsFor(game, game.playerId, false);
|
||||
game.state.party = [mon(game, TYPHLOSION), mon(game, MAGIKARP, { dvs })];
|
||||
expect(guru(game, [1])).toContain("seen a bigger one");
|
||||
expect(guru(game, [0])).toContain("That's not a\nMAGIKARP!");
|
||||
expect(guru(game, [-1])).toContain("didn't\nget one good");
|
||||
expect(game.save().magikarpRecord).toEqual({ feet: 3, inches: 6, holder: "RALPH" });
|
||||
// FindPartyMonThatSpecies reads wPartySpecies, where an Egg is EGG:
|
||||
// the guru only repeats his request.
|
||||
game.state.party = [mon(game, TYPHLOSION), mon(game, MAGIKARP, { dvs, egg: true })];
|
||||
expect(guru(game)).toContain("world's\nlargest MAGIKARP.");
|
||||
});
|
||||
|
||||
it("keeps the Elixer on standby when the pack is full", () => {
|
||||
const game = world();
|
||||
game.state.events.add(EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP);
|
||||
game.state.events.add(EVENT_LAKE_OF_RAGE_EXPLAINED_WEIRD_MAGIKARP);
|
||||
const { dvs } = dvsFor(game, game.playerId, true);
|
||||
game.state.party = [mon(game, MAGIKARP, { dvs })];
|
||||
// Twenty different items fill the Items pocket.
|
||||
game.state.bag.items = Array.from({ length: 20 }, (_, i) => ({ item: 0x10 + i, quantity: 1 }));
|
||||
guru(game, [0]);
|
||||
expect(game.state.events.has(EVENT_LAKE_OF_RAGE_ELIXIR_ON_STANDBY)).toBe(true);
|
||||
game.state.bag.items = [];
|
||||
expect(guru(game)).toContain("Take this as a\nmemento!");
|
||||
expect(game.state.events.has(EVENT_LAKE_OF_RAGE_ELIXIR_ON_STANDBY)).toBe(false);
|
||||
});
|
||||
|
||||
it("charges the Route 43 toll on every pass until the Rocket Hideout is cleared", () => {
|
||||
const game = world();
|
||||
game.state.party = [mon(game, TYPHLOSION)];
|
||||
game.state.money = 2500;
|
||||
/** Walk into the gate from Route 43's south door, facing up. */
|
||||
const pass = () => {
|
||||
game.warpTo(ROUTE_43, 17, 36);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: 17, y: 36, direction: "up", objects: [] });
|
||||
expect(game.move("up")).toBe(true);
|
||||
const shown = settle(game).join(" ");
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
expect(game.mapId).toBe(ROUTE_43_GATE);
|
||||
return shown;
|
||||
};
|
||||
expect(pass()).toContain("Thank you very\nmuch!");
|
||||
expect(game.state.money).toBe(1500);
|
||||
// Route 43's callback sets the gate's scene again on each visit (its
|
||||
// own setmapscene to scene-less Route 43 changes nothing).
|
||||
expect(pass()).toContain("The toll is ¥1000");
|
||||
expect(game.state.money).toBe(500);
|
||||
// Short of the toll, the Rockets take what there is.
|
||||
expect(pass()).toContain("pay what you\nhave");
|
||||
expect(game.state.money).toBe(0);
|
||||
game.state.events.add(EVENT_CLEARED_ROCKET_HIDEOUT);
|
||||
game.state.events.add(EVENT_ROUTE_43_GATE_ROCKETS);
|
||||
game.state.money = 1000;
|
||||
expect(pass()).not.toContain("toll");
|
||||
expect(game.state.money).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import type { Direction } from "../src/game/world/types";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import { applyTypeMatchups } from "../src/game/battle/battle";
|
||||
import { BattleData } from "../src/game/battle/data";
|
||||
import { afterRestore, findPath } from "./world-driver";
|
||||
import { afterRestore, findPath, landing } from "./world-driver";
|
||||
|
||||
const STEP: Record<Direction, [number, number]> = {
|
||||
up: [0, -1],
|
||||
@@ -223,19 +223,16 @@ export class Playthrough {
|
||||
// A surfer crosses water until it steps ashore.
|
||||
const water = world.state.playerState === PLAYER_SURF;
|
||||
for (const direction of findPath(world, x, y, { water })) {
|
||||
const [dx, dy] = STEP[direction];
|
||||
const from = [world.x, world.y];
|
||||
const end = landing(world, world.x, world.y, direction, {
|
||||
water,
|
||||
target: { x, y },
|
||||
});
|
||||
if (!world.move(direction)) {
|
||||
for (let tick = 0; tick < 64; tick++) world.tick();
|
||||
break;
|
||||
}
|
||||
this.play();
|
||||
if (
|
||||
world.mapId !== map ||
|
||||
world.x !== from[0] + dx ||
|
||||
world.y !== from[1] + dy
|
||||
)
|
||||
break;
|
||||
if (world.mapId !== map || world.x !== end?.x || world.y !== end.y) break;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
|
||||
@@ -143,6 +143,35 @@ const GOOD_ROD = 0x3b,
|
||||
EVENT_GOT_TM23_IRON_TAIL = 0xd,
|
||||
ENGINE_MINERALBADGE = 31,
|
||||
ENGINE_STORMBADGE = 32;
|
||||
const ROUTE_42_GATE = "2:4",
|
||||
MAHOGANY = "2:7",
|
||||
MAHOGANY_CENTER = "2:3",
|
||||
MAHOGANY_GYM = "2:2",
|
||||
MAHOGANY_SHOP = "3:48",
|
||||
ROCKET_HIDEOUT = ["3:49", "3:50", "3:51"],
|
||||
ROUTE_43_MAHOGANY_GATE = "9:3",
|
||||
ROUTE_43_GATE = "9:4",
|
||||
ROUTE_43 = "9:5",
|
||||
LAKE_OF_RAGE = "9:6";
|
||||
const RED_SCALE = 0x42,
|
||||
EVENT_BEAT_POKEMANIAC_SHANE = 0x4e7,
|
||||
EVENT_LAKE_OF_RAGE_RED_GYARADOS = 0x751,
|
||||
EVENT_DECIDED_TO_HELP_LANCE = 0x60,
|
||||
EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART = 0x2e2,
|
||||
EVENT_LANCE_HEALED_YOU_IN_TEAM_ROCKET_BASE = 0x4c,
|
||||
EVENT_LEARNED_SLOWPOKETAIL = 0x301,
|
||||
EVENT_LEARNED_RATICATE_TAIL = 0x302,
|
||||
EVENT_LEARNED_HAIL_GIOVANNI = 0x2ff,
|
||||
EVENT_RIVAL_TEAM_ROCKET_BASE = 0x6c0,
|
||||
EVENT_BEAT_ROCKET_EXECUTIVEM_4 = 0x574,
|
||||
EVENT_BEAT_ROCKET_EXECUTIVEF_2 = 0x570,
|
||||
EVENT_CLEARED_ROCKET_HIDEOUT = 0x22,
|
||||
EVENT_GOT_HM06_WHIRLPOOL = 0x15,
|
||||
EVENT_BEAT_PRYCE = 0x4c3,
|
||||
EVENT_GOT_TM16_ICY_WIND = 0xe,
|
||||
EVENT_RADIO_TOWER_ROCKET_TAKEOVER = 0x6ce,
|
||||
ENGINE_GLACIERBADGE = 0x21,
|
||||
ENGINE_ROCKETS_IN_RADIO_TOWER = 0x13;
|
||||
|
||||
/** The acceptance run for PLAN section 2: a new game played from the
|
||||
* introduction to the catching tutorial through the world's own inputs,
|
||||
@@ -1024,5 +1053,137 @@ describe.skipIf(!existsSync(romPath))(
|
||||
reload();
|
||||
writeTestSave("olivine-city");
|
||||
}, 240_000);
|
||||
it("crosses Route 42 past Mt. Mortar to Mahogany Town", () => {
|
||||
traceFailures();
|
||||
cross("up", ROUTE_39);
|
||||
cross("right", ROUTE_38);
|
||||
stairs(ROUTE_38_GATE);
|
||||
stairs(ECRUTEAK);
|
||||
stairs(ROUTE_42_GATE);
|
||||
stairs(ROUTE_42);
|
||||
// Route 42's pond splits it around Mt. Mortar; Krabby surfs across.
|
||||
cross("right", MAHOGANY, { water: true });
|
||||
expect(p.world.state.events.has(EVENT_BEAT_POKEMANIAC_SHANE)).toBe(true);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("pays the Rockets' toll on Route 43 and stops the Red Gyarados for Lance", () => {
|
||||
traceFailures();
|
||||
stairs(ROUTE_43_MAHOGANY_GATE);
|
||||
stairs(ROUTE_43);
|
||||
// Route43GateRocketShakedownScene: ¥1000 to go through.
|
||||
const money = p.world.state.money;
|
||||
stairs(ROUTE_43_GATE);
|
||||
expect(p.log.join(" ")).toContain("The toll is ¥1000");
|
||||
expect(p.world.state.money).toBe(money - 1000);
|
||||
stairs(ROUTE_43);
|
||||
cross("up", LAKE_OF_RAGE);
|
||||
// The Gyarados waits in the middle of the lake (18, 22).
|
||||
sail(18, 23);
|
||||
p.world.move("up");
|
||||
p.world.interact();
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_LAKE_OF_RAGE_RED_GYARADOS)).toBe(true);
|
||||
expect(p.world.state.bag.keyItems).toContain(RED_SCALE);
|
||||
// Lance appears on the shore (21, 28).
|
||||
sail(21, 29);
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play([1]);
|
||||
expect(p.world.state.events.has(EVENT_DECIDED_TO_HELP_LANCE)).toBe(true);
|
||||
expect(p.log.join(" ")).toContain("from MAHOGANY is\nthe cause.");
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("follows Lance into the Rocket Hideout and learns both passwords", () => {
|
||||
traceFailures();
|
||||
cross("down", ROUTE_43);
|
||||
// Route 43 sets the gate's shakedown scene again on every visit.
|
||||
stairs(ROUTE_43_GATE);
|
||||
stairs(ROUTE_43);
|
||||
stairs(ROUTE_43_MAHOGANY_GATE);
|
||||
stairs(MAHOGANY);
|
||||
heal(MAHOGANY_CENTER, MAHOGANY);
|
||||
// MahoganyMart1FLanceUncoversStaircaseScript: Dragonite's Hyper Beam.
|
||||
enter(MAHOGANY_SHOP, "up");
|
||||
expect(p.world.state.events.has(EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART)).toBe(true);
|
||||
// B1F's cameras and exploding traps lie on the way down.
|
||||
stairs(ROCKET_HIDEOUT[0]);
|
||||
stairs(ROCKET_HIDEOUT[1]);
|
||||
walk(5, 14); // LanceHealsScript1
|
||||
expect(p.world.state.events.has(EVENT_LANCE_HEALED_YOU_IN_TEAM_ROCKET_BASE)).toBe(true);
|
||||
// B3F: the grunts tell their passwords when talked to again.
|
||||
stairs(ROCKET_HIDEOUT[2]);
|
||||
expect(p.log.join(" ")).toContain("two passwords to");
|
||||
const [, , , slowpokeTail, raticateTail] = p.world.map.objects.map((object) => object.id);
|
||||
for (const grunt of [slowpokeTail, slowpokeTail, raticateTail, raticateTail]) {
|
||||
talkTo(grunt);
|
||||
play();
|
||||
}
|
||||
expect(p.world.state.events.has(EVENT_LEARNED_SLOWPOKETAIL)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_LEARNED_RATICATE_TAIL)).toBe(true);
|
||||
// The boss's door is reached from B2F's other stairs; the rival
|
||||
// waits on the way (RocketBaseRival).
|
||||
expect(route(ROCKET_HIDEOUT[2], ROCKET_HIDEOUT, 6, { x: 10, y: 10 })).toBe(true);
|
||||
walk(10, 10);
|
||||
expect(p.world.state.events.has(EVENT_RIVAL_TEAM_ROCKET_BASE)).toBe(true);
|
||||
p.world.move("up");
|
||||
p.world.interact();
|
||||
play();
|
||||
walk(10, 8); // RocketBaseBossLeft
|
||||
expect(p.world.state.events.has(EVENT_BEAT_ROCKET_EXECUTIVEM_4)).toBe(true);
|
||||
talkTo(p.world.map.objects[2].id); // the Murkrow
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_LEARNED_HAIL_GIOVANNI)).toBe(true);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("beats the executive with Lance and the Electrodes for HM06", () => {
|
||||
traceFailures();
|
||||
expect(route(ROCKET_HIDEOUT[1], ROCKET_HIDEOUT, 6, { x: 14, y: 13 })).toBe(true);
|
||||
walk(14, 13);
|
||||
p.world.move("up");
|
||||
p.world.interact();
|
||||
play();
|
||||
// RocketBaseBossFRight's coordinate event is two steps in.
|
||||
p.world.move("up");
|
||||
play();
|
||||
p.world.move("up");
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_BEAT_ROCKET_EXECUTIVEF_2)).toBe(true);
|
||||
// Lance takes the right-hand Electrodes; the player the left three.
|
||||
for (let round = 0; round < 6; round++) {
|
||||
const left = p.world.map.objects
|
||||
.slice(4, 7)
|
||||
.map((object) => object.id)
|
||||
.filter((id) => p.world.objects().some((object) => object.id === id));
|
||||
if (!left.length) break;
|
||||
talkTo(left[0]);
|
||||
play();
|
||||
}
|
||||
expect(p.world.state.events.has(EVENT_CLEARED_ROCKET_HIDEOUT)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_GOT_HM06_WHIRLPOOL)).toBe(true);
|
||||
expect(p.world.state.bag.tmsHms[55]).toBe(1); // HM06
|
||||
travel(MAHOGANY_SHOP, ROCKET_HIDEOUT);
|
||||
exit(MAHOGANY);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("slides across Pryce's ice for the GLACIERBADGE, and the Rockets take the Radio Tower", () => {
|
||||
traceFailures();
|
||||
heal(MAHOGANY_CENTER, MAHOGANY);
|
||||
enter(MAHOGANY_GYM, "up");
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play([1]);
|
||||
expect(p.world.state.events.has(EVENT_BEAT_PRYCE)).toBe(true);
|
||||
expect(p.world.state.flags.has(ENGINE_GLACIERBADGE)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_GOT_TM16_ICY_WIND)).toBe(true);
|
||||
expect(p.world.state.bag.tmsHms[15]).toBe(1); // TM16
|
||||
// The seventh badge runs RadioTowerRocketsScript.
|
||||
expect(p.world.state.flags.has(ENGINE_ROCKETS_IN_RADIO_TOWER)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_RADIO_TOWER_ROCKET_TAKEOVER)).toBe(false);
|
||||
exit(MAHOGANY);
|
||||
reload();
|
||||
writeTestSave("mahogany-town");
|
||||
}, 240_000);
|
||||
},
|
||||
);
|
||||
+65
-23
@@ -92,14 +92,75 @@ const isWarpHazard = (tile: number) =>
|
||||
* freely, unlike currents, waterfalls and whirlpools. */
|
||||
const isOpenWater = (tile: number) => tile === 0x29 || tile === 0x21;
|
||||
|
||||
/** COLL_ICE and COLL_ICE_2B: a step onto them keeps sliding (.CheckForced). */
|
||||
const isIce = (tile: number) => tile === 0x23 || tile === 0x2b;
|
||||
|
||||
/** Whether a step from (fromX, fromY) onto the next tile in `direction`
|
||||
* is open: walkable (or open water for a surfer), no side wall, no object,
|
||||
* and no warp other than the target. */
|
||||
function open(
|
||||
world: GameWorld,
|
||||
fromX: number,
|
||||
fromY: number,
|
||||
direction: Direction,
|
||||
water: boolean,
|
||||
target?: { x: number; y: number },
|
||||
) {
|
||||
const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!;
|
||||
const x = fromX + dx,
|
||||
y = fromY + dy;
|
||||
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height) return false;
|
||||
const tile = world.assets.collision(world.map, x, y);
|
||||
if (world.assets.permission(tile) !== 0 && !(water && isOpenWater(tile))) return false;
|
||||
if (
|
||||
edges(world.assets.collision(world.map, fromX, fromY)).includes(direction) ||
|
||||
edges(tile).includes(OPPOSITE[direction])
|
||||
)
|
||||
return false;
|
||||
if (world.objects().some((object) => object.x === x && object.y === y)) return false;
|
||||
if (
|
||||
isWarpHazard(tile) &&
|
||||
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
|
||||
(x !== target?.x || y !== target?.y)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Where a step in `direction` from (x, y) ends: the next tile, or, on
|
||||
* ice, the last tile of the slide before something blocks it. Undefined
|
||||
* when the step is blocked. */
|
||||
export function landing(
|
||||
world: GameWorld,
|
||||
x: number,
|
||||
y: number,
|
||||
direction: Direction,
|
||||
{ water = false, target }: { water?: boolean; target?: { x: number; y: number } } = {},
|
||||
): { x: number; y: number } | undefined {
|
||||
if (!open(world, x, y, direction, water, target)) return undefined;
|
||||
const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!;
|
||||
x += dx;
|
||||
y += dy;
|
||||
while (
|
||||
isIce(world.assets.collision(world.map, x, y)) &&
|
||||
open(world, x, y, direction, water, target)
|
||||
) {
|
||||
x += dx;
|
||||
y += dy;
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** Shortest walkable path on the current map, avoiding other warps.
|
||||
* `water` also crosses open water, for a player who can Surf. */
|
||||
* `water` also crosses open water, for a player who can Surf; a step onto
|
||||
* ice slides on until blocked. */
|
||||
export function findPath(
|
||||
world: GameWorld,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
{ water = false } = {},
|
||||
): Direction[] {
|
||||
const target = { x: targetX, y: targetY };
|
||||
const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }];
|
||||
const visited = new Set<string>();
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
@@ -108,28 +169,9 @@ export function findPath(
|
||||
if (visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
if (node.x === targetX && node.y === targetY) return node.path;
|
||||
for (const [direction, dx, dy] of DIRECTIONS) {
|
||||
const x = node.x + dx,
|
||||
y = node.y + dy;
|
||||
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
|
||||
continue;
|
||||
const tile = world.assets.collision(world.map, x, y);
|
||||
if (world.assets.permission(tile) !== 0 && !(water && isOpenWater(tile)))
|
||||
continue;
|
||||
if (
|
||||
edges(world.assets.collision(world.map, node.x, node.y)).includes(direction) ||
|
||||
edges(tile).includes(OPPOSITE[direction])
|
||||
)
|
||||
continue;
|
||||
if (world.objects().some((object) => object.x === x && object.y === y))
|
||||
continue;
|
||||
if (
|
||||
isWarpHazard(tile) &&
|
||||
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
|
||||
(x !== targetX || y !== targetY)
|
||||
)
|
||||
continue;
|
||||
queue.push({ x, y, path: [...node.path, direction] });
|
||||
for (const [direction] of DIRECTIONS) {
|
||||
const end = landing(world, node.x, node.y, direction, { water, target });
|
||||
if (end) queue.push({ ...end, path: [...node.path, direction] });
|
||||
}
|
||||
}
|
||||
throw new Error(`No route to ${targetX},${targetY}`);
|
||||
|
||||
Reference in new issue
Block a user