@quest-editor/ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/index.d.ts +158 -0
- package/dist/index.js +381 -0
- package/package.json +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 pipobizelli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# @quest-editor/ai
|
|
2
|
+
|
|
3
|
+
Monster movement AI for HeroQuest — per-type behavior + pathfinding. **Pure and
|
|
4
|
+
engine-agnostic**: no React, no DB, no rendering, no `Math.random`. It talks only to a small
|
|
5
|
+
`BoardQuery` port that each consumer implements, and takes an injected seeded RNG, so the same
|
|
6
|
+
code drives the **tracker** (suggested moves) and the future **online** game (authoritative,
|
|
7
|
+
replayable). Design: `../../docs/monster-ai.md`.
|
|
8
|
+
|
|
9
|
+
## Status
|
|
10
|
+
|
|
11
|
+
- ✅ **Pathfinding** (milestone 1): `distanceField`, `stepToward`, `aStar`, `reachableFrom`.
|
|
12
|
+
- ✅ **Behavior** (milestone 2): `decideMonsterTurn` — per-type policies (Mind = AI tier),
|
|
13
|
+
target selection, approach, attack, and flee.
|
|
14
|
+
- ⬜ Coordination (allies focus-fire), ranged/spell intents — later.
|
|
15
|
+
|
|
16
|
+
## The port
|
|
17
|
+
|
|
18
|
+
Implement `BoardQuery` over your board state (the tracker maps it from a live session's
|
|
19
|
+
`board_state` + revealed fog; the online game from its authoritative state):
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
interface BoardQuery {
|
|
23
|
+
readonly width: number
|
|
24
|
+
readonly height: number
|
|
25
|
+
passable(t: Tile): boolean // enterable terrain (not void/rock)
|
|
26
|
+
occupant(t: Tile): 'hero' | 'monster' | null
|
|
27
|
+
blockedEdge(a: Tile, b: Tile): boolean // wall or closed/unrevealed door between adjacent tiles
|
|
28
|
+
lineOfSight(a: Tile, b: Tile): boolean // ranged/visibility (unused by pathfinding)
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Pathfinding
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { distanceField, stepToward, makeRng } from '@quest-editor/ai'
|
|
36
|
+
|
|
37
|
+
const rng = makeRng(seed)
|
|
38
|
+
// One multi-source pass from the heroes → cost-to-nearest-hero for every tile:
|
|
39
|
+
const field = distanceField(board, heroes /* Tile[] */)
|
|
40
|
+
// Each monster descends the field within its movement budget (no per-agent search):
|
|
41
|
+
const path = stepToward(board, field, monster.pos, MONSTER_STATS[subtype].movement, rng)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **Why a distance field, not per-monster A\*:** the grid is tiny (~500 tiles) and many
|
|
45
|
+
monsters share one goal set (the heroes). Computing the field once and letting every monster
|
|
46
|
+
read it is `O(tiles)` + `O(budget)` per monster — strictly cheaper than N separate searches,
|
|
47
|
+
and it yields nearest-hero + direction + reachability for free.
|
|
48
|
+
- **Opportunity Attack:** a monster may path **through** a hero's square (never end on it).
|
|
49
|
+
Passing costs `DEFAULT_HERO_PASS_COST` extra, so routes detour around heroes when cheap but
|
|
50
|
+
flood through when that's the only way in — the chokepoint fix from `heroquest-rules`.
|
|
51
|
+
- **Determinism:** stable neighbor order + index tie-breaks; the injected RNG only breaks ties
|
|
52
|
+
between equally-good steps. Same inputs + seed ⇒ identical paths.
|
|
53
|
+
- Recompute the field each Zargon turn (state changes); it's cheap.
|
|
54
|
+
|
|
55
|
+
`aStar(board, from, to)` is a fallback for "path to this exact empty tile" when a behavior
|
|
56
|
+
picks a specific destination.
|
|
57
|
+
|
|
58
|
+
## Behavior
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { decideMonsterTurn } from '@quest-editor/ai'
|
|
62
|
+
|
|
63
|
+
// Per Zargon turn, for each living monster (recompute as the board changes):
|
|
64
|
+
const decisions = decideMonsterTurn({
|
|
65
|
+
self: { id, pos, subtype: 'goblin', body }, // body = current
|
|
66
|
+
heroes: [{ id, pos, subtype, body, threat }], // threat = danger score (e.g. attack dice)
|
|
67
|
+
board,
|
|
68
|
+
rng,
|
|
69
|
+
// stats?: { movement, mind, body } ← pass core's MONSTER_STATS to be authoritative
|
|
70
|
+
})
|
|
71
|
+
// → [{ kind:'move', path, to }, { kind:'attack', targetId, from }] | [{ kind:'wait' }]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- **Mind = AI tier:** 0 mindless (skeleton/zombie/mummy → nearest hero), 1-2 instinct
|
|
75
|
+
(orc aggressive; **goblin = opportunistic coward**), 3-4 tactical (fimir/chaos/gargoyle →
|
|
76
|
+
highest-threat target, **retreat when wounded/surrounded**).
|
|
77
|
+
- **Stats** come from a built-in table mirroring `@quest-editor/core` (so the package is
|
|
78
|
+
dependency-free); pass `stats` to override with the canonical values.
|
|
79
|
+
- Decisions are serializable intents — the app resolves combat and applies movement. The
|
|
80
|
+
tracker renders them as *suggestions*; the online server applies them authoritatively.
|
|
81
|
+
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/** A board square (grid coordinate). */
|
|
2
|
+
interface Tile {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
}
|
|
6
|
+
/** Who occupies a square — affects movement (monsters block; heroes are pass-through). */
|
|
7
|
+
type Faction = 'hero' | 'monster';
|
|
8
|
+
/**
|
|
9
|
+
* The board PORT. The AI talks only to this — never to a concrete board model.
|
|
10
|
+
* Each consumer (tracker, online) implements an adapter over its own state.
|
|
11
|
+
*
|
|
12
|
+
* HeroQuest grid semantics: orthogonal movement; walls/doors live on the EDGE
|
|
13
|
+
* between two squares (hence `blockedEdge`, not per-tile walls).
|
|
14
|
+
*/
|
|
15
|
+
interface BoardQuery {
|
|
16
|
+
readonly width: number;
|
|
17
|
+
readonly height: number;
|
|
18
|
+
/** Is this square part of the playable dungeon and enterable terrain (not void/rock)? */
|
|
19
|
+
passable(t: Tile): boolean;
|
|
20
|
+
/** Current occupant of the square, or null if empty. */
|
|
21
|
+
occupant(t: Tile): Faction | null;
|
|
22
|
+
/**
|
|
23
|
+
* Is movement across the edge between ADJACENT tiles `a`→`b` blocked?
|
|
24
|
+
* Covers walls and closed/unrevealed doors (fog). Must be symmetric.
|
|
25
|
+
*/
|
|
26
|
+
blockedEdge(a: Tile, b: Tile): boolean;
|
|
27
|
+
/** Unobstructed straight line (for ranged attacks / visibility). Unused by pathfinding. */
|
|
28
|
+
lineOfSight(a: Tile, b: Tile): boolean;
|
|
29
|
+
}
|
|
30
|
+
/** Deterministic RNG: returns a float in [0, 1). Injected so the AI is reproducible. */
|
|
31
|
+
type RNG = () => number;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Seeded deterministic RNG (mulberry32). Same seed → same stream, on any machine.
|
|
35
|
+
* Used instead of `Math.random()` so monster decisions are reproducible (server/client
|
|
36
|
+
* agreement, replays, snapshot tests).
|
|
37
|
+
*/
|
|
38
|
+
declare function makeRng(seed: number): RNG;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pathfinding over the BoardQuery port. Tuned for the actual shape of the problem:
|
|
42
|
+
* a tiny grid (~500 tiles) with many agents sharing one goal set (the heroes).
|
|
43
|
+
*
|
|
44
|
+
* Primary primitive = a **distance field** (Dijkstra map): one multi-source pass from
|
|
45
|
+
* the heroes' attack-adjacent tiles yields cost-to-nearest-hero for every tile. Every
|
|
46
|
+
* monster then gradient-descends that field within its movement budget — no per-agent
|
|
47
|
+
* search. `aStar` is a fallback for one-off "path to this exact tile" queries.
|
|
48
|
+
*
|
|
49
|
+
* Opportunity Attack (heroquest-rules/homebrew/opportunity-attack.md): a monster may move
|
|
50
|
+
* THROUGH a hero's square (never end on it). Passing costs `heroPassCost` extra, so the
|
|
51
|
+
* field detours around heroes when cheap but floods through when that's the only way in.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** Sentinel for "unreachable" in a distance field. */
|
|
55
|
+
declare const UNREACHABLE = 2147483647;
|
|
56
|
+
/**
|
|
57
|
+
* Extra cost to path THROUGH a hero's square (the AoO risk). A monster will detour up to
|
|
58
|
+
* this many tiles to avoid it before passing through. Tunable behavior knob.
|
|
59
|
+
*/
|
|
60
|
+
declare const DEFAULT_HERO_PASS_COST = 8;
|
|
61
|
+
/**
|
|
62
|
+
* Multi-source Dijkstra map: distance (in movement cost) from every tile to the nearest
|
|
63
|
+
* valid attack position next to any `goal` (hero). Returns a flat `Int32Array` indexed by
|
|
64
|
+
* `y * width + x`; unreachable tiles hold `UNREACHABLE`.
|
|
65
|
+
*
|
|
66
|
+
* `goals` is the goal set — pass all heroes to chase the nearest, or one hero to focus-fire.
|
|
67
|
+
*/
|
|
68
|
+
declare function distanceField(board: BoardQuery, goals: Tile[], opts?: {
|
|
69
|
+
heroPassCost?: number;
|
|
70
|
+
}): Int32Array;
|
|
71
|
+
/**
|
|
72
|
+
* Greedily descend a distance field from `from`, up to `budget` steps, returning the path
|
|
73
|
+
* (excluding `from`). The path may pass THROUGH hero squares but never ENDS on one (trailing
|
|
74
|
+
* pass-through tiles are trimmed). Stops on reaching an attack-adjacent tile (field 0).
|
|
75
|
+
*
|
|
76
|
+
* Contract: call only when the monster is not already in attack range — deciding that is the
|
|
77
|
+
* behavior layer's job. `rng` breaks ties between equally-good steps (varied yet reproducible).
|
|
78
|
+
*/
|
|
79
|
+
declare function stepToward(board: BoardQuery, field: Int32Array, from: Tile, budget: number, rng: RNG): Tile[];
|
|
80
|
+
/**
|
|
81
|
+
* A* shortest path from `from` to an exact free tile `to` (orthogonal, uniform cost).
|
|
82
|
+
* Treats every occupied tile as blocked (this is the "go to a specific empty tile" helper;
|
|
83
|
+
* hero pass-through is the distance field's job). Returns the path excluding `from`, `[]`
|
|
84
|
+
* if already there, or `null` if `to` is invalid/occupied/unreachable.
|
|
85
|
+
*/
|
|
86
|
+
declare function aStar(board: BoardQuery, from: Tile, to: Tile): Tile[] | null;
|
|
87
|
+
/**
|
|
88
|
+
* Unweighted BFS of every tile reachable from `from` within `budget` steps.
|
|
89
|
+
* Returns `dist` (steps to each tile, `UNREACHABLE` if not reached) and `came`
|
|
90
|
+
* (predecessor index for path reconstruction). Monster tiles always block; hero
|
|
91
|
+
* tiles block unless `throughHeroes` is set (movement may pass through, fleeing
|
|
92
|
+
* may not — passing a hero would provoke the Opportunity Attack).
|
|
93
|
+
*/
|
|
94
|
+
declare function reachableFrom(board: BoardQuery, from: Tile, budget: number, opts?: {
|
|
95
|
+
throughHeroes?: boolean;
|
|
96
|
+
}): {
|
|
97
|
+
dist: Int32Array;
|
|
98
|
+
came: Int32Array;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
interface Combatant {
|
|
102
|
+
id: string;
|
|
103
|
+
pos: Tile;
|
|
104
|
+
/** Monster: a MONSTER_STATS subtype. Hero: its class (unused by the AI). */
|
|
105
|
+
subtype: string;
|
|
106
|
+
/** Current Body (alive if > 0). */
|
|
107
|
+
body: number;
|
|
108
|
+
/** Hero danger score (e.g. attack dice) — drives tactical targeting & goblin caution. */
|
|
109
|
+
threat?: number;
|
|
110
|
+
}
|
|
111
|
+
/** The acting monster's stats the AI needs (mirrors @quest-editor/core MONSTER_STATS). */
|
|
112
|
+
interface MonsterStats {
|
|
113
|
+
/** Squares it may move this turn (monsters use a fixed value; heroes roll). */
|
|
114
|
+
movement: number;
|
|
115
|
+
/** Intelligence → AI tier (0 mindless · 1-2 instinct · 3-4 tactical). */
|
|
116
|
+
mind: number;
|
|
117
|
+
/** Max Body — used to gauge "wounded" for the flee decision. */
|
|
118
|
+
body: number;
|
|
119
|
+
}
|
|
120
|
+
interface MonsterTurnInput {
|
|
121
|
+
self: Combatant;
|
|
122
|
+
heroes: Combatant[];
|
|
123
|
+
/** Other living monsters (reserved for coordination; unused in v0 beyond presence). */
|
|
124
|
+
allies?: Combatant[];
|
|
125
|
+
board: BoardQuery;
|
|
126
|
+
rng: RNG;
|
|
127
|
+
/** Authoritative stats for `self`; if omitted, the built-in table is used by subtype. */
|
|
128
|
+
stats?: MonsterStats;
|
|
129
|
+
/** Override the Opportunity-Attack pass-through cost used while pathing. */
|
|
130
|
+
heroPassCost?: number;
|
|
131
|
+
}
|
|
132
|
+
type Decision = {
|
|
133
|
+
kind: 'move';
|
|
134
|
+
path: Tile[];
|
|
135
|
+
to: Tile;
|
|
136
|
+
} | {
|
|
137
|
+
kind: 'attack';
|
|
138
|
+
targetId: string;
|
|
139
|
+
from: Tile;
|
|
140
|
+
} | {
|
|
141
|
+
kind: 'wait';
|
|
142
|
+
};
|
|
143
|
+
/** A hero with threat ≥ this is "tough" — a lone goblin won't engage it. */
|
|
144
|
+
declare const GOBLIN_TOUGH_THREAT = 3;
|
|
145
|
+
/** Adjacent heroes ≥ this = "surrounded" — a tactical monster retreats. */
|
|
146
|
+
declare const SURROUNDED = 3;
|
|
147
|
+
/** 0 = mindless · 1 = instinct · 2 = tactical. */
|
|
148
|
+
declare function monsterTier(mind: number): 0 | 1 | 2;
|
|
149
|
+
/** Heroes orthogonally adjacent to `pos` with an open edge (attackable right now). */
|
|
150
|
+
declare function adjacentAttackableHeroes(board: BoardQuery, pos: Tile, heroes: Combatant[]): Combatant[];
|
|
151
|
+
/**
|
|
152
|
+
* Decide one monster's turn: a list of intents (`move` then maybe `attack`, or `wait`).
|
|
153
|
+
* Deterministic for a given seed. Call once per monster per Zargon turn (recompute as the
|
|
154
|
+
* board changes between monsters).
|
|
155
|
+
*/
|
|
156
|
+
declare function decideMonsterTurn(input: MonsterTurnInput): Decision[];
|
|
157
|
+
|
|
158
|
+
export { type BoardQuery, type Combatant, DEFAULT_HERO_PASS_COST, type Decision, type Faction, GOBLIN_TOUGH_THREAT, type MonsterStats, type MonsterTurnInput, type RNG, SURROUNDED, type Tile, UNREACHABLE, aStar, adjacentAttackableHeroes, decideMonsterTurn, distanceField, makeRng, monsterTier, reachableFrom, stepToward };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// src/rng.ts
|
|
2
|
+
function makeRng(seed) {
|
|
3
|
+
let a = seed >>> 0;
|
|
4
|
+
return () => {
|
|
5
|
+
a = a + 1831565813 | 0;
|
|
6
|
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
7
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
8
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// src/pathfinding.ts
|
|
13
|
+
var UNREACHABLE = 2147483647;
|
|
14
|
+
var DEFAULT_HERO_PASS_COST = 8;
|
|
15
|
+
var DIRS = [
|
|
16
|
+
[0, -1],
|
|
17
|
+
[-1, 0],
|
|
18
|
+
[1, 0],
|
|
19
|
+
[0, 1]
|
|
20
|
+
];
|
|
21
|
+
var inBounds = (b, x, y) => x >= 0 && y >= 0 && x < b.width && y < b.height;
|
|
22
|
+
var enterCost = (b, t, heroPassCost) => b.occupant(t) === "hero" ? 1 + heroPassCost : 1;
|
|
23
|
+
var MinHeap = class {
|
|
24
|
+
dist = [];
|
|
25
|
+
node = [];
|
|
26
|
+
get size() {
|
|
27
|
+
return this.node.length;
|
|
28
|
+
}
|
|
29
|
+
push(d, n) {
|
|
30
|
+
this.dist.push(d);
|
|
31
|
+
this.node.push(n);
|
|
32
|
+
let c = this.node.length - 1;
|
|
33
|
+
while (c > 0) {
|
|
34
|
+
const p = c - 1 >> 1;
|
|
35
|
+
if (this.less(c, p)) {
|
|
36
|
+
this.swap(c, p);
|
|
37
|
+
c = p;
|
|
38
|
+
} else break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
pop() {
|
|
42
|
+
const top = this.node[0];
|
|
43
|
+
const d = this.dist.pop();
|
|
44
|
+
const n = this.node.pop();
|
|
45
|
+
if (this.node.length) {
|
|
46
|
+
this.dist[0] = d;
|
|
47
|
+
this.node[0] = n;
|
|
48
|
+
let p = 0;
|
|
49
|
+
for (; ; ) {
|
|
50
|
+
const l = 2 * p + 1;
|
|
51
|
+
const r = 2 * p + 2;
|
|
52
|
+
let s = p;
|
|
53
|
+
if (l < this.node.length && this.less(l, s)) s = l;
|
|
54
|
+
if (r < this.node.length && this.less(r, s)) s = r;
|
|
55
|
+
if (s === p) break;
|
|
56
|
+
this.swap(s, p);
|
|
57
|
+
p = s;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return top;
|
|
61
|
+
}
|
|
62
|
+
less(a, b) {
|
|
63
|
+
return this.dist[a] < this.dist[b] || this.dist[a] === this.dist[b] && this.node[a] < this.node[b];
|
|
64
|
+
}
|
|
65
|
+
swap(a, b) {
|
|
66
|
+
const d = this.dist[a];
|
|
67
|
+
this.dist[a] = this.dist[b];
|
|
68
|
+
this.dist[b] = d;
|
|
69
|
+
const n = this.node[a];
|
|
70
|
+
this.node[a] = this.node[b];
|
|
71
|
+
this.node[b] = n;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
function distanceField(board, goals, opts = {}) {
|
|
75
|
+
const heroPassCost = opts.heroPassCost ?? DEFAULT_HERO_PASS_COST;
|
|
76
|
+
const W = board.width;
|
|
77
|
+
const dist = new Int32Array(W * board.height).fill(UNREACHABLE);
|
|
78
|
+
const heap = new MinHeap();
|
|
79
|
+
for (const g of goals) {
|
|
80
|
+
for (const [dx, dy] of DIRS) {
|
|
81
|
+
const nx = g.x + dx;
|
|
82
|
+
const ny = g.y + dy;
|
|
83
|
+
if (!inBounds(board, nx, ny)) continue;
|
|
84
|
+
const n = { x: nx, y: ny };
|
|
85
|
+
if (!board.passable(n) || board.occupant(n) !== null) continue;
|
|
86
|
+
if (board.blockedEdge(n, g)) continue;
|
|
87
|
+
const id = ny * W + nx;
|
|
88
|
+
if (dist[id] !== 0) {
|
|
89
|
+
dist[id] = 0;
|
|
90
|
+
heap.push(0, id);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
while (heap.size) {
|
|
95
|
+
const cur = heap.pop();
|
|
96
|
+
const cx = cur % W;
|
|
97
|
+
const cy = cur / W | 0;
|
|
98
|
+
const curT = { x: cx, y: cy };
|
|
99
|
+
const base = dist[cur];
|
|
100
|
+
for (const [dx, dy] of DIRS) {
|
|
101
|
+
const mx = cx + dx;
|
|
102
|
+
const my = cy + dy;
|
|
103
|
+
if (!inBounds(board, mx, my)) continue;
|
|
104
|
+
const m = { x: mx, y: my };
|
|
105
|
+
if (!board.passable(m) || board.occupant(m) === "monster") continue;
|
|
106
|
+
if (board.blockedEdge(m, curT)) continue;
|
|
107
|
+
const cost = base + enterCost(board, curT, heroPassCost);
|
|
108
|
+
const mid = my * W + mx;
|
|
109
|
+
if (cost < dist[mid]) {
|
|
110
|
+
dist[mid] = cost;
|
|
111
|
+
heap.push(cost, mid);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return dist;
|
|
116
|
+
}
|
|
117
|
+
function stepToward(board, field, from, budget, rng) {
|
|
118
|
+
const W = board.width;
|
|
119
|
+
const path = [];
|
|
120
|
+
let cur = from;
|
|
121
|
+
let curVal = field[from.y * W + from.x];
|
|
122
|
+
for (let step = 0; step < budget; step++) {
|
|
123
|
+
let best = UNREACHABLE;
|
|
124
|
+
let cands = [];
|
|
125
|
+
for (const [dx, dy] of DIRS) {
|
|
126
|
+
const nx = cur.x + dx;
|
|
127
|
+
const ny = cur.y + dy;
|
|
128
|
+
if (!inBounds(board, nx, ny)) continue;
|
|
129
|
+
const n = { x: nx, y: ny };
|
|
130
|
+
if (!board.passable(n) || board.occupant(n) === "monster") continue;
|
|
131
|
+
if (board.blockedEdge(cur, n)) continue;
|
|
132
|
+
const v = field[ny * W + nx];
|
|
133
|
+
if (v >= UNREACHABLE) continue;
|
|
134
|
+
if (v < best) {
|
|
135
|
+
best = v;
|
|
136
|
+
cands = [n];
|
|
137
|
+
} else if (v === best) {
|
|
138
|
+
cands.push(n);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (cands.length === 0) break;
|
|
142
|
+
if (curVal < UNREACHABLE && best >= curVal) break;
|
|
143
|
+
const next = cands.length === 1 ? cands[0] : cands[Math.floor(rng() * cands.length)];
|
|
144
|
+
path.push(next);
|
|
145
|
+
cur = next;
|
|
146
|
+
curVal = best;
|
|
147
|
+
if (best === 0) break;
|
|
148
|
+
}
|
|
149
|
+
while (path.length && board.occupant(path[path.length - 1]) !== null) path.pop();
|
|
150
|
+
return path;
|
|
151
|
+
}
|
|
152
|
+
function aStar(board, from, to) {
|
|
153
|
+
if (from.x === to.x && from.y === to.y) return [];
|
|
154
|
+
if (!inBounds(board, to.x, to.y) || !board.passable(to) || board.occupant(to) !== null) return null;
|
|
155
|
+
const W = board.width;
|
|
156
|
+
const N = W * board.height;
|
|
157
|
+
const start = from.y * W + from.x;
|
|
158
|
+
const goal = to.y * W + to.x;
|
|
159
|
+
const g = new Int32Array(N).fill(UNREACHABLE);
|
|
160
|
+
const came = new Int32Array(N).fill(-1);
|
|
161
|
+
const h = (x, y) => Math.abs(x - to.x) + Math.abs(y - to.y);
|
|
162
|
+
const heap = new MinHeap();
|
|
163
|
+
g[start] = 0;
|
|
164
|
+
heap.push(h(from.x, from.y), start);
|
|
165
|
+
while (heap.size) {
|
|
166
|
+
const cur = heap.pop();
|
|
167
|
+
if (cur === goal) break;
|
|
168
|
+
const cx = cur % W;
|
|
169
|
+
const cy = cur / W | 0;
|
|
170
|
+
const curT = { x: cx, y: cy };
|
|
171
|
+
for (const [dx, dy] of DIRS) {
|
|
172
|
+
const nx = cx + dx;
|
|
173
|
+
const ny = cy + dy;
|
|
174
|
+
if (!inBounds(board, nx, ny)) continue;
|
|
175
|
+
const n = { x: nx, y: ny };
|
|
176
|
+
if (!board.passable(n) || board.occupant(n) !== null || board.blockedEdge(curT, n)) continue;
|
|
177
|
+
const nid = ny * W + nx;
|
|
178
|
+
const ng = g[cur] + 1;
|
|
179
|
+
if (ng < g[nid]) {
|
|
180
|
+
g[nid] = ng;
|
|
181
|
+
came[nid] = cur;
|
|
182
|
+
heap.push(ng + h(nx, ny), nid);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (g[goal] >= UNREACHABLE) return null;
|
|
187
|
+
const path = [];
|
|
188
|
+
let c = goal;
|
|
189
|
+
while (c !== start) {
|
|
190
|
+
path.push({ x: c % W, y: c / W | 0 });
|
|
191
|
+
c = came[c];
|
|
192
|
+
if (c === -1) return null;
|
|
193
|
+
}
|
|
194
|
+
path.reverse();
|
|
195
|
+
return path;
|
|
196
|
+
}
|
|
197
|
+
function reachableFrom(board, from, budget, opts = {}) {
|
|
198
|
+
const through = opts.throughHeroes ?? false;
|
|
199
|
+
const W = board.width;
|
|
200
|
+
const N = W * board.height;
|
|
201
|
+
const dist = new Int32Array(N).fill(UNREACHABLE);
|
|
202
|
+
const came = new Int32Array(N).fill(-1);
|
|
203
|
+
const startId = from.y * W + from.x;
|
|
204
|
+
dist[startId] = 0;
|
|
205
|
+
let frontier = [startId];
|
|
206
|
+
for (let steps = 0; steps < budget && frontier.length; steps++) {
|
|
207
|
+
const next = [];
|
|
208
|
+
for (const cur of frontier) {
|
|
209
|
+
const cx = cur % W;
|
|
210
|
+
const cy = cur / W | 0;
|
|
211
|
+
const curT = { x: cx, y: cy };
|
|
212
|
+
for (const [dx, dy] of DIRS) {
|
|
213
|
+
const nx = cx + dx;
|
|
214
|
+
const ny = cy + dy;
|
|
215
|
+
if (!inBounds(board, nx, ny)) continue;
|
|
216
|
+
const n = { x: nx, y: ny };
|
|
217
|
+
if (!board.passable(n) || board.blockedEdge(curT, n)) continue;
|
|
218
|
+
const occ = board.occupant(n);
|
|
219
|
+
if (occ === "monster" || occ === "hero" && !through) continue;
|
|
220
|
+
const nid = ny * W + nx;
|
|
221
|
+
if (dist[nid] !== UNREACHABLE) continue;
|
|
222
|
+
dist[nid] = steps + 1;
|
|
223
|
+
came[nid] = cur;
|
|
224
|
+
next.push(nid);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
frontier = next;
|
|
228
|
+
}
|
|
229
|
+
return { dist, came };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/behavior.ts
|
|
233
|
+
var GOBLIN_TOUGH_THREAT = 3;
|
|
234
|
+
var SURROUNDED = 3;
|
|
235
|
+
var BASE_STATS = {
|
|
236
|
+
goblin: { movement: 10, mind: 1, body: 1 },
|
|
237
|
+
orc: { movement: 8, mind: 2, body: 1 },
|
|
238
|
+
fimir: { movement: 6, mind: 3, body: 2 },
|
|
239
|
+
skeleton: { movement: 6, mind: 0, body: 1 },
|
|
240
|
+
zombie: { movement: 5, mind: 0, body: 1 },
|
|
241
|
+
mummy: { movement: 4, mind: 0, body: 2 },
|
|
242
|
+
chaos: { movement: 7, mind: 3, body: 3 },
|
|
243
|
+
gargoyle: { movement: 6, mind: 4, body: 3 }
|
|
244
|
+
};
|
|
245
|
+
var DEFAULT_STATS = { movement: 6, mind: 0, body: 1 };
|
|
246
|
+
var statOf = (subtype) => BASE_STATS[subtype] ?? DEFAULT_STATS;
|
|
247
|
+
function monsterTier(mind) {
|
|
248
|
+
return mind === 0 ? 0 : mind <= 2 ? 1 : 2;
|
|
249
|
+
}
|
|
250
|
+
var adjacentTiles = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y) === 1;
|
|
251
|
+
var canAttack = (board, from, hero) => adjacentTiles(from, hero.pos) && !board.blockedEdge(from, hero.pos);
|
|
252
|
+
function adjacentAttackableHeroes(board, pos, heroes) {
|
|
253
|
+
return heroes.filter((h) => canAttack(board, pos, h));
|
|
254
|
+
}
|
|
255
|
+
var isTough = (h) => (h.threat ?? 0) >= GOBLIN_TOUGH_THREAT;
|
|
256
|
+
var weakest = (list) => list.reduce((b, h) => h.body < b.body || h.body === b.body && h.id < b.id ? h : b);
|
|
257
|
+
var strongest = (list) => list.reduce((b, h) => {
|
|
258
|
+
const t = h.threat ?? 0;
|
|
259
|
+
const bt = b.threat ?? 0;
|
|
260
|
+
return t > bt || t === bt && h.id < b.id ? h : b;
|
|
261
|
+
});
|
|
262
|
+
function approachInfo(board, hero, self, budget, heroPassCost) {
|
|
263
|
+
const field = distanceField(board, [hero.pos], { heroPassCost });
|
|
264
|
+
if (canAttack(board, self.pos, hero)) return { hero, field, dist: 0, reachable: true };
|
|
265
|
+
const W = board.width;
|
|
266
|
+
let best = UNREACHABLE;
|
|
267
|
+
for (const [dx, dy] of [
|
|
268
|
+
[0, -1],
|
|
269
|
+
[-1, 0],
|
|
270
|
+
[1, 0],
|
|
271
|
+
[0, 1]
|
|
272
|
+
]) {
|
|
273
|
+
const nx = self.pos.x + dx;
|
|
274
|
+
const ny = self.pos.y + dy;
|
|
275
|
+
if (nx < 0 || ny < 0 || nx >= board.width || ny >= board.height) continue;
|
|
276
|
+
const n = { x: nx, y: ny };
|
|
277
|
+
if (!board.passable(n) || board.occupant(n) === "monster" || board.blockedEdge(self.pos, n)) continue;
|
|
278
|
+
const v = field[ny * W + nx];
|
|
279
|
+
if (v < best) best = v;
|
|
280
|
+
}
|
|
281
|
+
const dist = best >= UNREACHABLE ? UNREACHABLE : best + 1;
|
|
282
|
+
return { hero, field, dist, reachable: dist <= budget };
|
|
283
|
+
}
|
|
284
|
+
function chooseApproachTarget(tier, subtype, infos) {
|
|
285
|
+
const pathable = infos.filter((i) => i.dist < UNREACHABLE);
|
|
286
|
+
if (!pathable.length) return null;
|
|
287
|
+
const reachable = pathable.filter((i) => i.reachable);
|
|
288
|
+
const pool = reachable.length ? reachable : pathable;
|
|
289
|
+
const byClosest = (a, b) => a.dist - b.dist || (a.hero.id < b.hero.id ? -1 : 1);
|
|
290
|
+
const byWounded = (a, b) => a.hero.body - b.hero.body || byClosest(a, b);
|
|
291
|
+
if (subtype === "goblin") {
|
|
292
|
+
const soft = pool.filter((i) => !isTough(i.hero));
|
|
293
|
+
return [...soft.length ? soft : pool].sort(byWounded)[0];
|
|
294
|
+
}
|
|
295
|
+
if (tier === 0) return [...pool].sort(byClosest)[0];
|
|
296
|
+
if (tier === 1) return [...pool].sort(byWounded)[0];
|
|
297
|
+
return [...pool].sort(
|
|
298
|
+
(a, b) => (b.hero.threat ?? 0) - (a.hero.threat ?? 0) || byClosest(a, b)
|
|
299
|
+
)[0];
|
|
300
|
+
}
|
|
301
|
+
var isDisadvantaged = (self, pressure) => self.body <= 1 || pressure >= SURROUNDED;
|
|
302
|
+
function flee(board, danger, self, budget) {
|
|
303
|
+
const { dist, came } = reachableFrom(board, self.pos, budget, { throughHeroes: false });
|
|
304
|
+
const W = board.width;
|
|
305
|
+
let bestId = -1;
|
|
306
|
+
let bestSafety = -1;
|
|
307
|
+
let bestSteps = Infinity;
|
|
308
|
+
for (let id = 0; id < dist.length; id++) {
|
|
309
|
+
if (dist[id] === UNREACHABLE || dist[id] === 0) continue;
|
|
310
|
+
const x = id % W;
|
|
311
|
+
const y = id / W | 0;
|
|
312
|
+
if (board.occupant({ x, y }) !== null) continue;
|
|
313
|
+
const d = danger[id];
|
|
314
|
+
const safety = d >= UNREACHABLE ? UNREACHABLE - 1 : d;
|
|
315
|
+
if (safety > bestSafety || safety === bestSafety && dist[id] < bestSteps) {
|
|
316
|
+
bestSafety = safety;
|
|
317
|
+
bestSteps = dist[id];
|
|
318
|
+
bestId = id;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (bestId < 0) return [];
|
|
322
|
+
const path = [];
|
|
323
|
+
const startId = self.pos.y * W + self.pos.x;
|
|
324
|
+
for (let c = bestId; c !== startId && c !== -1; c = came[c]) path.push({ x: c % W, y: c / W | 0 });
|
|
325
|
+
path.reverse();
|
|
326
|
+
return path;
|
|
327
|
+
}
|
|
328
|
+
function decideMonsterTurn(input) {
|
|
329
|
+
const { self, board, rng, heroPassCost } = input;
|
|
330
|
+
const heroes = input.heroes.filter((h) => h.body > 0);
|
|
331
|
+
if (!heroes.length) return [{ kind: "wait" }];
|
|
332
|
+
const stats = input.stats ?? statOf(self.subtype);
|
|
333
|
+
const budget = stats.movement;
|
|
334
|
+
const tier = monsterTier(stats.mind);
|
|
335
|
+
const subtype = self.subtype;
|
|
336
|
+
const adj = adjacentAttackableHeroes(board, self.pos, heroes);
|
|
337
|
+
const pressure = adj.length;
|
|
338
|
+
const fleeDecision = () => {
|
|
339
|
+
const danger = distanceField(board, heroes.map((h) => h.pos), { heroPassCost });
|
|
340
|
+
const path2 = flee(board, danger, self, budget);
|
|
341
|
+
if (path2.length) return [{ kind: "move", path: path2, to: path2[path2.length - 1] }];
|
|
342
|
+
if (adj.length) return [{ kind: "attack", targetId: weakest(adj).id, from: self.pos }];
|
|
343
|
+
return [{ kind: "wait" }];
|
|
344
|
+
};
|
|
345
|
+
if (adj.length) {
|
|
346
|
+
if (subtype === "goblin") {
|
|
347
|
+
const soft = adj.filter((h) => !isTough(h));
|
|
348
|
+
if (soft.length) return [{ kind: "attack", targetId: weakest(soft).id, from: self.pos }];
|
|
349
|
+
return fleeDecision();
|
|
350
|
+
}
|
|
351
|
+
if (tier === 2 && isDisadvantaged(self, pressure)) return fleeDecision();
|
|
352
|
+
const target = tier === 2 ? strongest(adj) : weakest(adj);
|
|
353
|
+
return [{ kind: "attack", targetId: target.id, from: self.pos }];
|
|
354
|
+
}
|
|
355
|
+
if (tier === 2 && isDisadvantaged(self, pressure)) return fleeDecision();
|
|
356
|
+
const infos = heroes.map((h) => approachInfo(board, h, self, budget, heroPassCost));
|
|
357
|
+
const chosen = chooseApproachTarget(tier, subtype, infos);
|
|
358
|
+
if (!chosen) return [{ kind: "wait" }];
|
|
359
|
+
const path = stepToward(board, chosen.field, self.pos, budget, rng);
|
|
360
|
+
const endPos = path.length ? path[path.length - 1] : self.pos;
|
|
361
|
+
const decisions = [];
|
|
362
|
+
if (path.length) decisions.push({ kind: "move", path, to: endPos });
|
|
363
|
+
if (canAttack(board, endPos, chosen.hero))
|
|
364
|
+
decisions.push({ kind: "attack", targetId: chosen.hero.id, from: endPos });
|
|
365
|
+
if (!decisions.length) decisions.push({ kind: "wait" });
|
|
366
|
+
return decisions;
|
|
367
|
+
}
|
|
368
|
+
export {
|
|
369
|
+
DEFAULT_HERO_PASS_COST,
|
|
370
|
+
GOBLIN_TOUGH_THREAT,
|
|
371
|
+
SURROUNDED,
|
|
372
|
+
UNREACHABLE,
|
|
373
|
+
aStar,
|
|
374
|
+
adjacentAttackableHeroes,
|
|
375
|
+
decideMonsterTurn,
|
|
376
|
+
distanceField,
|
|
377
|
+
makeRng,
|
|
378
|
+
monsterTier,
|
|
379
|
+
reachableFrom,
|
|
380
|
+
stepToward
|
|
381
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@quest-editor/ai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"development": "./src/index.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"tsup": "^8.4.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup src/index.ts --format esm --dts --tsconfig tsconfig.build.json",
|
|
27
|
+
"dev": "tsup src/index.ts --format esm --dts --watch"
|
|
28
|
+
}
|
|
29
|
+
}
|