Compare commits
19 Commits
e3014e47a8
...
fb66ec55c4
| Author | SHA1 | Date |
|---|---|---|
|
|
fb66ec55c4 | |
|
|
8155747cac | |
|
|
7601a97ec9 | |
|
|
f775d51a58 | |
|
|
aa36f3ea67 | |
|
|
0f04af2c6e | |
|
|
c11bceeb44 | |
|
|
1d749f59a6 | |
|
|
65afe6dc8f | |
|
|
3a135a4ad1 | |
|
|
4deebf67c3 | |
|
|
1c238aec3a | |
|
|
a469b4024a | |
|
|
7d8684a16f | |
|
|
3dc566c2fd | |
|
|
3f3490fad8 | |
|
|
5e55b58c43 | |
|
|
f8c008b67d | |
|
|
c0fa0e91b2 |
|
|
@ -1,5 +1,5 @@
|
||||||
import {Part} from "./part";
|
import {Part} from "./part";
|
||||||
import {RNG} from "@/utils/rng";
|
import {ReadonlyRNG} from "@/utils/rng";
|
||||||
|
|
||||||
export type Region = {
|
export type Region = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -107,7 +107,7 @@ export function applyAlign<TMeta>(region: Region, parts: Record<string, Part<TMe
|
||||||
region.partMap = buildPartMap(region, parts);
|
region.partMap = buildPartMap(region, parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shuffle<TMeta>(region: Region, parts: Record<string, Part<TMeta>>, rng: RNG){
|
export function shuffle<TMeta>(region: Region, parts: Record<string, Part<TMeta>>, rng: ReadonlyRNG){
|
||||||
if (region.childIds.length <= 1) return;
|
if (region.childIds.length <= 1) return;
|
||||||
|
|
||||||
const childIds = [...region.childIds];
|
const childIds = [...region.childIds];
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
# slay-the-spire-like
|
|
||||||
|
|
||||||
A Slay the Spire + Backpack Heroes hybrid roguelike sample. Players explore a point-crawl map, manage a tetris-style grid inventory, and fight enemies using cards generated from their equipment.
|
|
||||||
|
|
||||||
## Game Design Docs
|
|
||||||
|
|
||||||
Design docs are in the markdown files at this level:
|
|
||||||
- `01-overview.md` — core game concept, zones, encounter structure, combat rules, buff/debuff system
|
|
||||||
- `02-fighter.md` — Fighter class items (weapons, armor, tools, consumables, relics)
|
|
||||||
- `03-desert.md` — Desert zone enemies (minions, elites, boss)
|
|
||||||
- `data/rules.md` — combat state machine, turn order, effect timing rules
|
|
||||||
|
|
||||||
## Module Structure
|
|
||||||
|
|
||||||
This is **not** a `GameModule` yet — there is no `createInitialState`/`start`/`registry` wired up to `createGameHost`. The code is a library of subsystems that can be composed into a game module.
|
|
||||||
|
|
||||||
### Subsystems
|
|
||||||
|
|
||||||
| Directory | Purpose | Key exports |
|
|
||||||
|-----------|---------|-------------|
|
|
||||||
| `progress/` | Run state, player HP/gold, inventory management, map progression | `createRunState`, `moveToNode`, `resolveEncounter`, `damagePlayer`, `healPlayer`, `addItemFromCsv`, `removeItem`, `getReachableChildren` |
|
|
||||||
| `map/` | Point-crawl map generation and traversal | `generatePointCrawlMap`, `getNode`, `getChildren`, `getParents`, `hasPath`, `findAllPaths` |
|
|
||||||
| `grid-inventory/` | Tetris-style grid placement (place, move, rotate, flip items) | `createGridInventory`, `placeItem`, `removeItem`, `moveItem`, `rotateItem`, `flipItem`, `validatePlacement`, `getAdjacentItems` |
|
|
||||||
| `deck/` | Card/deck system (draw pile, hand, discard, exhaust) | `generateDeckFromInventory`, `createStatusCard`, `createDeckRegions`, `createPlayerDeck` |
|
|
||||||
| `data/` | CSV game data loaded via `inline-schema/csv-loader`. `.d.ts` files are auto-generated by the csv-loader plugin — do not edit by hand. | `heroItemFighter1Data`, `encounterDesertData`, `enemyDesertData`, `enemyIntentDesertData`, `effectDesertData`, `statusCardDesertData` |
|
|
||||||
| `dialogue/` | Yarn Spinner dialogue files (placeholder). Loaded via `yarn-spinner-loader`, a local peer dependency at `../yarn-spinner-loader` (like `inline-schema`, it can be changed and published if needed). | `encounters` yarnproject |
|
|
||||||
| `utils/` | Shape parsing and collision math | `parseShapeString`, `checkCollision`, `checkBounds`, `transformShape`, `rotateTransform`, `flipXTransform`, `flipYTransform` |
|
|
||||||
|
|
||||||
### Data flow
|
|
||||||
|
|
||||||
```
|
|
||||||
CSV files (data/)
|
|
||||||
→ inline-schema/csv-loader → typed JS objects (e.g. HeroItemFighter1)
|
|
||||||
→ parseShapeString() converts shape strings → ParsedShape
|
|
||||||
→ GridInventory<GameItemMeta> holds placed items
|
|
||||||
→ generateDeckFromInventory() generates cards per occupied cell
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key types
|
|
||||||
|
|
||||||
- **`RunState`** — top-level state: seed, map, player, inventory, currentNodeId, encounter state, resolved set. Designed for `MutableSignal.produce()` mutation.
|
|
||||||
- **`GridInventory<TMeta>`** — `items: Map<string, InventoryItem<TMeta>>` + `occupiedCells: Set<CellKey>` for O(1) collision. Mutated directly inside `.produce()`.
|
|
||||||
- **`InventoryItem<TMeta>`** — id, shape (ParsedShape), transform (Transform2D), meta. Shape + transform determines which cells are occupied.
|
|
||||||
- **`GameCard`** — a `Part<GameCardMeta>` bridging inventory items to the deck system. `sourceItemId` links back to the inventory item; `null` for status cards.
|
|
||||||
- **`PointCrawlMap`** — layered DAG: 10 layers (start → wild×2 → settlement → wild×2 → settlement → wild×2 → end). Wild = 3 nodes, Settlement = 4 nodes.
|
|
||||||
- **`MapNode`** — id, type (MapNodeType enum), childIds, optional encounter data from CSV.
|
|
||||||
|
|
||||||
### Map generation
|
|
||||||
|
|
||||||
`generatePointCrawlMap(seed?)` produces a deterministic map:
|
|
||||||
- 10 layers: Start → Wild(3) → Wild(3) → Settlement(4) → Wild(3) → Wild(3) → Settlement(4) → Wild(3) → Wild(3) → End
|
|
||||||
- Settlement layers guarantee ≥1 camp, ≥1 shop, ≥1 curio (4th slot random)
|
|
||||||
- Wild pair types are optimized to minimize same-type repetition
|
|
||||||
- Edge patterns avoid crossings: Start→all wild, Wild→Wild 1:1, Wild↔Settlement 3:4 or 4:3, Wild→all End
|
|
||||||
|
|
||||||
### Shape system
|
|
||||||
|
|
||||||
Items have shapes defined as movement strings parsed by `parseShapeString`:
|
|
||||||
- `o` = origin cell, `n/s/e/w` = move + fill, `r` = return to previous position
|
|
||||||
- Example: `"oesw"` = 2×2 block (origin, east, south, west = full square)
|
|
||||||
- Example: `"oe"` = 1×2 horizontal
|
|
||||||
- Example: `"onrersrw"` = cross/X shape
|
|
||||||
|
|
||||||
Shapes are positioned via `Transform2D` (offset, rotation, flipX, flipY) and validated against the 6×4 grid.
|
|
||||||
|
|
||||||
### Grid inventory
|
|
||||||
|
|
||||||
All mutation functions (`placeItem`, `removeItem`, `moveItem`, `rotateItem`, `flipItem`) mutate the `GridInventory` **directly** — they must be called inside `produce()` callbacks. `validatePlacement` checks bounds + collisions before placement.
|
|
||||||
|
|
||||||
### Card generation
|
|
||||||
|
|
||||||
`generateDeckFromInventory(inventory)` creates one card per occupied cell in each item's shape. Cards carry `GameCardMeta` linking back to the source item and cell position. Status cards (wound, venom, etc.) are created separately via `createStatusCard`.
|
|
||||||
|
|
||||||
## CSV data format
|
|
||||||
|
|
||||||
All CSVs use `inline-schema` typed headers. The first row is a comment header, the second row is the schema row with types and references:
|
|
||||||
- `'energy'|'uses'` — union type
|
|
||||||
- `@enemyDesert` — foreign key reference to another CSV
|
|
||||||
- `[effect: @effectDesert; number][]` — array of structured references
|
|
||||||
|
|
||||||
### heroItemFighter1.csv columns
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|--------|------|-------|
|
|
||||||
| type | `'weapon'|'armor'|'consumable'|'tool'` | |
|
|
||||||
| name | string | Display name (Chinese) |
|
|
||||||
| shape | string | Movement string for `parseShapeString` |
|
|
||||||
| costType | `'energy'|'uses'` | Energy = per-turn cost; Uses = limited uses |
|
|
||||||
| costCount | int | Cost amount |
|
|
||||||
| targetType | `'single'|'none'` | |
|
|
||||||
| price | int | Shop price |
|
|
||||||
| desc | string | Ability description (Chinese) |
|
|
||||||
| effects | `['self'|'target'|'all'|'random'; @effectDesert; number][]` | Effect references |
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- Chinese is used for all user-facing strings (item names, error messages, effect descriptions)
|
|
||||||
- Discriminated union result types: `{ success: true } | { success: false, reason: string }`
|
|
||||||
- Mutation functions mutate state directly (inside `produce()`); validation is separate
|
|
||||||
- `Map` and `Set` are used in `GridInventory` and `PointCrawlMap` (not plain objects) — requires careful handling with `mutative` since it drafts Maps/Sets differently than plain objects
|
|
||||||
- Starter items defined in `progress/index.ts`: `['治疗药剂', '绷带', '水袋', '短刀', '剑']`
|
|
||||||
- Default player stats: 50 HP, 50 gold, 6×4 inventory
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# 《背包爬塔》肉鸽
|
|
||||||
|
|
||||||
|
|
@ -1,538 +0,0 @@
|
||||||
import type { EffectDesert } from "../data/effectDesert.csv";
|
|
||||||
import type { CardDesert } from "../data/cardDesert.csv";
|
|
||||||
import { effectDesertData, cardDesertData } from "../data";
|
|
||||||
import { createStatusCard } from "../deck/factory";
|
|
||||||
import type { PlayerDeck, GameCard } from "../deck/types";
|
|
||||||
import type {
|
|
||||||
BuffTable,
|
|
||||||
CombatEffectEntry,
|
|
||||||
CombatState,
|
|
||||||
EffectTarget,
|
|
||||||
EffectTiming,
|
|
||||||
EnemyState,
|
|
||||||
ItemBuff,
|
|
||||||
PlayerCombatState,
|
|
||||||
} from "./types";
|
|
||||||
import {
|
|
||||||
drawCardsToHand,
|
|
||||||
addFatigueCards,
|
|
||||||
discardCard,
|
|
||||||
exhaustCard,
|
|
||||||
getEffectData,
|
|
||||||
discardHand,
|
|
||||||
} from "./state";
|
|
||||||
|
|
||||||
export type DamageResult = {
|
|
||||||
damageDealt: number;
|
|
||||||
blockedByDefend: number;
|
|
||||||
targetDied: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function applyDamage(
|
|
||||||
state: CombatState,
|
|
||||||
targetKey: "player" | string,
|
|
||||||
amount: number,
|
|
||||||
sourceKey?: "player" | string,
|
|
||||||
): DamageResult {
|
|
||||||
if (amount <= 0) return { damageDealt: 0, blockedByDefend: 0, targetDied: false };
|
|
||||||
|
|
||||||
let actualDamage = amount;
|
|
||||||
let blockedByDefend = 0;
|
|
||||||
|
|
||||||
if (targetKey === "player") {
|
|
||||||
const defendStacks = state.player.buffs["defend"] ?? 0;
|
|
||||||
if (defendStacks > 0) {
|
|
||||||
blockedByDefend = Math.min(defendStacks, actualDamage);
|
|
||||||
actualDamage -= blockedByDefend;
|
|
||||||
state.player.buffs["defend"] = defendStacks - blockedByDefend;
|
|
||||||
if (state.player.buffs["defend"] === 0) {
|
|
||||||
delete state.player.buffs["defend"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const damageReduce = state.player.buffs["damageReduce"] ?? 0;
|
|
||||||
if (damageReduce > 0 && actualDamage > 0) {
|
|
||||||
actualDamage = Math.max(0, actualDamage - damageReduce);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actualDamage > 0) {
|
|
||||||
state.player.hp = Math.max(0, state.player.hp - actualDamage);
|
|
||||||
state.player.damageTakenThisTurn += actualDamage;
|
|
||||||
state.player.damagedThisTurn = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blockedByDefend > 0 && defendStacks - blockedByDefend <= 0) {
|
|
||||||
for (const enemyId of state.enemyOrder) {
|
|
||||||
const enemy = state.enemies[enemyId];
|
|
||||||
if (enemy.isAlive && enemy.buffs["defend"] !== undefined) {
|
|
||||||
// Not relevant for player, skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
damageDealt: actualDamage,
|
|
||||||
blockedByDefend,
|
|
||||||
targetDied: state.player.hp <= 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const enemy = state.enemies[targetKey];
|
|
||||||
if (!enemy || !enemy.isAlive) {
|
|
||||||
return { damageDealt: 0, blockedByDefend: 0, targetDied: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const defendStacks = enemy.buffs["defend"] ?? 0;
|
|
||||||
if (defendStacks > 0) {
|
|
||||||
blockedByDefend = Math.min(defendStacks, actualDamage);
|
|
||||||
actualDamage -= blockedByDefend;
|
|
||||||
enemy.buffs["defend"] = defendStacks - blockedByDefend;
|
|
||||||
if (enemy.buffs["defend"] === 0) {
|
|
||||||
delete enemy.buffs["defend"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (defendStacks > 0 && defendStacks - blockedByDefend <= 0) {
|
|
||||||
enemy.hadDefendBroken = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actualDamage > 0) {
|
|
||||||
enemy.hp = Math.max(0, enemy.hp - actualDamage);
|
|
||||||
if (enemy.hp <= 0) {
|
|
||||||
enemy.isAlive = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
damageDealt: actualDamage,
|
|
||||||
blockedByDefend,
|
|
||||||
targetDied: !enemy.isAlive,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyDefend(
|
|
||||||
targetBuffs: BuffTable,
|
|
||||||
amount: number,
|
|
||||||
): void {
|
|
||||||
if (amount <= 0) return;
|
|
||||||
targetBuffs["defend"] = (targetBuffs["defend"] ?? 0) + amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyBuff(
|
|
||||||
buffs: BuffTable,
|
|
||||||
effectId: string,
|
|
||||||
timing: EffectTiming,
|
|
||||||
stacks: number,
|
|
||||||
): void {
|
|
||||||
if (stacks <= 0) return;
|
|
||||||
buffs[effectId] = (buffs[effectId] ?? 0) + stacks;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeBuff(buffs: BuffTable, effectId: string, stacks?: number): number {
|
|
||||||
const current = buffs[effectId] ?? 0;
|
|
||||||
if (stacks === undefined || stacks >= current) {
|
|
||||||
delete buffs[effectId];
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
buffs[effectId] = current - stacks;
|
|
||||||
return stacks;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateBuffs(buffs: BuffTable): void {
|
|
||||||
const toDelete: string[] = [];
|
|
||||||
const toDecrement: string[] = [];
|
|
||||||
|
|
||||||
for (const [effectId] of Object.entries(buffs)) {
|
|
||||||
const effectData = getEffectData(effectId);
|
|
||||||
if (!effectData) continue;
|
|
||||||
|
|
||||||
switch (effectData.timing) {
|
|
||||||
case "temporary":
|
|
||||||
toDelete.push(effectId);
|
|
||||||
break;
|
|
||||||
case "lingering":
|
|
||||||
toDecrement.push(effectId);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const id of toDelete) {
|
|
||||||
delete buffs[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const id of toDecrement) {
|
|
||||||
buffs[id] = (buffs[id] ?? 0) - 1;
|
|
||||||
if (buffs[id] <= 0) {
|
|
||||||
delete buffs[id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ResolveEffectContext = {
|
|
||||||
state: CombatState;
|
|
||||||
rng: { nextInt: (n: number) => number };
|
|
||||||
};
|
|
||||||
|
|
||||||
export function resolveEffect(
|
|
||||||
ctx: ResolveEffectContext,
|
|
||||||
target: EffectTarget,
|
|
||||||
effect: EffectDesert,
|
|
||||||
stacks: number,
|
|
||||||
sourceKey?: "player" | string,
|
|
||||||
sourceCardId?: string,
|
|
||||||
): void {
|
|
||||||
const { state } = ctx;
|
|
||||||
const timing = effect.timing;
|
|
||||||
|
|
||||||
switch (timing) {
|
|
||||||
case "instant":
|
|
||||||
resolveInstantEffect(ctx, target, effect, stacks, sourceKey, sourceCardId);
|
|
||||||
break;
|
|
||||||
case "posture":
|
|
||||||
applyBuffToTarget(state, target, effect.id, stacks, sourceKey);
|
|
||||||
break;
|
|
||||||
case "temporary":
|
|
||||||
case "lingering":
|
|
||||||
case "permanent":
|
|
||||||
applyBuffToTarget(state, target, effect.id, stacks, sourceKey);
|
|
||||||
break;
|
|
||||||
case "card":
|
|
||||||
addStatusCardToDiscard(state, effect.id, stacks);
|
|
||||||
break;
|
|
||||||
case "cardDraw":
|
|
||||||
addStatusCardToDrawPile(state, effect.id, stacks);
|
|
||||||
break;
|
|
||||||
case "cardHand":
|
|
||||||
addStatusCardToHand(state, effect.id, stacks);
|
|
||||||
break;
|
|
||||||
case "item":
|
|
||||||
case "itemUntilPlayed":
|
|
||||||
applyItemBuff(state, effect.id, timing, stacks, sourceCardId);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyBuffToTarget(
|
|
||||||
state: CombatState,
|
|
||||||
target: EffectTarget,
|
|
||||||
effectId: string,
|
|
||||||
stacks: number,
|
|
||||||
sourceKey?: "player" | string,
|
|
||||||
): void {
|
|
||||||
if (target === "self") {
|
|
||||||
if (sourceKey === "player") {
|
|
||||||
applyBuff(state.player.buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
|
|
||||||
} else if (sourceKey && state.enemies[sourceKey]) {
|
|
||||||
applyBuff(state.enemies[sourceKey].buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
|
|
||||||
}
|
|
||||||
} else if (target === "player" || target === "team") {
|
|
||||||
applyBuff(state.player.buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
|
|
||||||
} else if (target === "target" || target === "all" || target === "random") {
|
|
||||||
// For attack/defend effects, these are handled by resolveInstantEffect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveInstantEffect(
|
|
||||||
ctx: ResolveEffectContext,
|
|
||||||
target: EffectTarget,
|
|
||||||
effect: EffectDesert,
|
|
||||||
stacks: number,
|
|
||||||
sourceKey?: "player" | string,
|
|
||||||
sourceCardId?: string,
|
|
||||||
): void {
|
|
||||||
const { state, rng } = ctx;
|
|
||||||
|
|
||||||
switch (effect.id) {
|
|
||||||
case "attack": {
|
|
||||||
const damageAmount = stacks;
|
|
||||||
if (target === "all") {
|
|
||||||
for (const enemyId of state.enemyOrder) {
|
|
||||||
const enemy = state.enemies[enemyId];
|
|
||||||
if (enemy.isAlive) {
|
|
||||||
applyDamage(state, enemyId, damageAmount, sourceKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (target === "random") {
|
|
||||||
const aliveEnemies = state.enemyOrder.filter(id => state.enemies[id].isAlive);
|
|
||||||
if (aliveEnemies.length > 0) {
|
|
||||||
const targetId = aliveEnemies[rng.nextInt(aliveEnemies.length)];
|
|
||||||
applyDamage(state, targetId, damageAmount, sourceKey);
|
|
||||||
}
|
|
||||||
} else if (target === "target") {
|
|
||||||
if (sourceKey && sourceKey !== "player" && state.enemies[sourceKey]?.isAlive) {
|
|
||||||
applyDamage(state, "player", damageAmount, sourceKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "defend": {
|
|
||||||
if (target === "self" && sourceKey === "player") {
|
|
||||||
applyDefend(state.player.buffs, stacks);
|
|
||||||
} else if (target === "self" && sourceKey && state.enemies[sourceKey]) {
|
|
||||||
applyDefend(state.enemies[sourceKey].buffs, stacks);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "draw": {
|
|
||||||
drawCardsToHand(state.player.deck, stacks);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "gainEnergy": {
|
|
||||||
state.player.energy += stacks;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "removeWound": {
|
|
||||||
removeWoundCards(state.player.deck, stacks);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "tailSting": {
|
|
||||||
applyDamage(state, "player", stacks, sourceKey);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "rollDamage": {
|
|
||||||
const rollStacks = sourceKey && sourceKey !== "player"
|
|
||||||
? state.enemies[sourceKey]?.buffs["roll"] ?? 0
|
|
||||||
: 0;
|
|
||||||
if (rollStacks >= 10) {
|
|
||||||
const damageFromRoll = Math.floor(rollStacks / 10) * 10;
|
|
||||||
applyDamage(state, "player", Math.floor(damageFromRoll / 10), sourceKey);
|
|
||||||
removeBuff(state.enemies[sourceKey!].buffs, "roll", damageFromRoll);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "crossbow": {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "discard": {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "summonMummy":
|
|
||||||
case "summonSandwormLarva":
|
|
||||||
case "reviveMummy": {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "drawChoice":
|
|
||||||
case "transformRandom": {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addStatusCardToDiscard(state: CombatState, effectId: string, count: number): void {
|
|
||||||
const cardDef = cardDesertData.find(c => c.id === effectId);
|
|
||||||
if (!cardDef) return;
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const cardId = `status-${effectId}-${Date.now()}-${i}`;
|
|
||||||
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
|
|
||||||
state.player.deck.cards[card.id] = card;
|
|
||||||
state.player.deck.discardPile.push(card.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addStatusCardToDrawPile(state: CombatState, effectId: string, count: number): void {
|
|
||||||
const cardDef = cardDesertData.find(c => c.id === effectId);
|
|
||||||
if (!cardDef) return;
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const cardId = `status-${effectId}-${Date.now()}-${i}`;
|
|
||||||
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
|
|
||||||
state.player.deck.cards[card.id] = card;
|
|
||||||
state.player.deck.drawPile.push(card.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addStatusCardToHand(state: CombatState, effectId: string, count: number): void {
|
|
||||||
const cardDef = cardDesertData.find(c => c.id === effectId);
|
|
||||||
if (!cardDef) return;
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const cardId = `status-${effectId}-${Date.now()}-${i}`;
|
|
||||||
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
|
|
||||||
state.player.deck.cards[card.id] = card;
|
|
||||||
state.player.deck.hand.push(card.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyItemBuff(
|
|
||||||
state: CombatState,
|
|
||||||
effectId: string,
|
|
||||||
timing: EffectTiming,
|
|
||||||
stacks: number,
|
|
||||||
sourceCardId?: string,
|
|
||||||
): void {
|
|
||||||
if (!sourceCardId) return;
|
|
||||||
|
|
||||||
const card = state.player.deck.cards[sourceCardId];
|
|
||||||
if (!card || !card.sourceItemId) return;
|
|
||||||
|
|
||||||
const itemBuff: ItemBuff = {
|
|
||||||
effectId,
|
|
||||||
stacks,
|
|
||||||
timing,
|
|
||||||
sourceItemId: card.sourceItemId,
|
|
||||||
targetItemId: card.sourceItemId,
|
|
||||||
};
|
|
||||||
state.player.itemBuffs.push(itemBuff);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeWoundCards(deck: PlayerDeck, count: number): void {
|
|
||||||
let removed = 0;
|
|
||||||
|
|
||||||
for (let i = deck.drawPile.length - 1; i >= 0 && removed < count; i--) {
|
|
||||||
const card = deck.cards[deck.drawPile[i]];
|
|
||||||
if (card && card.itemData === null && card.displayName === "伤口") {
|
|
||||||
delete deck.cards[deck.drawPile[i]];
|
|
||||||
deck.drawPile.splice(i, 1);
|
|
||||||
removed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = deck.discardPile.length - 1; i >= 0 && removed < count; i--) {
|
|
||||||
const card = deck.cards[deck.discardPile[i]];
|
|
||||||
if (card && card.itemData === null && card.displayName === "伤口") {
|
|
||||||
delete deck.cards[deck.discardPile[i]];
|
|
||||||
deck.discardPile.splice(i, 1);
|
|
||||||
removed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveCardEffects(
|
|
||||||
ctx: ResolveEffectContext,
|
|
||||||
cardId: string,
|
|
||||||
targetEnemyId?: string,
|
|
||||||
): void {
|
|
||||||
const { state } = ctx;
|
|
||||||
const card = state.player.deck.cards[cardId];
|
|
||||||
if (!card || !card.itemData) return;
|
|
||||||
|
|
||||||
const sourceKey: "player" | string = "player";
|
|
||||||
|
|
||||||
const effects = card.itemData.onPlay as unknown as CombatEffectEntry[];
|
|
||||||
for (const entry of effects) {
|
|
||||||
const [target, effect, stacks] = entry;
|
|
||||||
|
|
||||||
if (target === "target") {
|
|
||||||
if (targetEnemyId && state.enemies[targetEnemyId]?.isAlive) {
|
|
||||||
if (effect.id === "attack") {
|
|
||||||
const actualDamage = getModifiedAttackDamage(state, cardId, stacks);
|
|
||||||
applyDamage(state, targetEnemyId, actualDamage, "player");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveEffect(ctx, target, effect, stacks, sourceKey, cardId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getModifiedAttackDamage(
|
|
||||||
state: CombatState,
|
|
||||||
cardId: string,
|
|
||||||
baseDamage: number,
|
|
||||||
): number {
|
|
||||||
let damage = baseDamage;
|
|
||||||
|
|
||||||
const attackBuff = state.player.itemBuffs
|
|
||||||
.filter((b) => b.effectId === "attackBuff" || b.effectId === "attackBuffUntilPlay")
|
|
||||||
.filter((b) => {
|
|
||||||
const card = state.player.deck.cards[cardId];
|
|
||||||
return card && card.sourceItemId === b.targetItemId;
|
|
||||||
})
|
|
||||||
.reduce((sum, b) => sum + b.stacks, 0);
|
|
||||||
damage += attackBuff;
|
|
||||||
|
|
||||||
return Math.max(0, damage);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getModifiedDefendAmount(
|
|
||||||
state: CombatState,
|
|
||||||
cardId: string,
|
|
||||||
baseDefend: number,
|
|
||||||
): number {
|
|
||||||
let defend = baseDefend;
|
|
||||||
|
|
||||||
const defendBuff = state.player.itemBuffs
|
|
||||||
.filter((b) => b.effectId === "defendBuff" || b.effectId === "defendBuffUntilPlay")
|
|
||||||
.filter((b) => {
|
|
||||||
const card = state.player.deck.cards[cardId];
|
|
||||||
return card && card.sourceItemId === b.targetItemId;
|
|
||||||
})
|
|
||||||
.reduce((sum, b) => sum + b.stacks, 0);
|
|
||||||
defend += defendBuff;
|
|
||||||
|
|
||||||
return Math.max(0, defend);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canPlayCard(
|
|
||||||
state: CombatState,
|
|
||||||
cardId: string,
|
|
||||||
): { canPlay: boolean; reason?: string } {
|
|
||||||
const card = state.player.deck.cards[cardId];
|
|
||||||
if (!card) return { canPlay: false, reason: "卡牌不存在" };
|
|
||||||
|
|
||||||
if (!card.itemData) return { canPlay: false, reason: "状态牌不可打出" };
|
|
||||||
|
|
||||||
const handIdx = state.player.deck.hand.indexOf(cardId);
|
|
||||||
if (handIdx < 0) return { canPlay: false, reason: "卡牌不在手牌中" };
|
|
||||||
|
|
||||||
if (card.itemData.costType === "energy") {
|
|
||||||
if (state.player.energy < card.itemData.costCount) {
|
|
||||||
return { canPlay: false, reason: "能量不足" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { canPlay: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function playCard(
|
|
||||||
ctx: ResolveEffectContext,
|
|
||||||
cardId: string,
|
|
||||||
targetEnemyId?: string,
|
|
||||||
): { success: boolean; reason?: string } {
|
|
||||||
const { state } = ctx;
|
|
||||||
const check = canPlayCard(state, cardId);
|
|
||||||
if (!check.canPlay) return { success: false, reason: check.reason };
|
|
||||||
|
|
||||||
const card = state.player.deck.cards[cardId];
|
|
||||||
if (!card || !card.itemData) return { success: false, reason: "卡牌无效" };
|
|
||||||
|
|
||||||
if (card.itemData.costType === "energy") {
|
|
||||||
state.player.energy -= card.itemData.costCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveCardEffects(ctx, cardId, targetEnemyId);
|
|
||||||
|
|
||||||
if (card.itemData.costType === "uses") {
|
|
||||||
exhaustCard(state.player.deck, cardId);
|
|
||||||
} else {
|
|
||||||
discardCard(state.player.deck, cardId);
|
|
||||||
}
|
|
||||||
|
|
||||||
expireItemBuffsOnCardPlayed(state, cardId);
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function expireItemBuffsOnCardPlayed(state: CombatState, cardId: string): void {
|
|
||||||
const card = state.player.deck.cards[cardId];
|
|
||||||
if (!card || !card.sourceItemId) return;
|
|
||||||
|
|
||||||
state.player.itemBuffs = state.player.itemBuffs.filter((buff) => {
|
|
||||||
if (buff.timing === "itemUntilPlayed" && buff.sourceItemId === card.sourceItemId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function areAllEnemiesDead(state: CombatState): boolean {
|
|
||||||
return state.enemyOrder.every(id => !state.enemies[id].isAlive);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPlayerDead(state: CombatState): boolean {
|
|
||||||
return state.player.hp <= 0;
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
export type {
|
|
||||||
BuffTable,
|
|
||||||
CombatEffectEntry,
|
|
||||||
CombatEntity,
|
|
||||||
CombatGameContext,
|
|
||||||
CombatPhase,
|
|
||||||
CombatResult,
|
|
||||||
CombatState,
|
|
||||||
EffectTarget,
|
|
||||||
EffectTiming,
|
|
||||||
EnemyState,
|
|
||||||
ItemBuff,
|
|
||||||
LootEntry,
|
|
||||||
PlayerCombatState,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export {
|
|
||||||
createCombatState,
|
|
||||||
createEnemyInstance,
|
|
||||||
createPlayerCombatState,
|
|
||||||
drawCardsToHand,
|
|
||||||
reshuffleDiscardIntoDraw,
|
|
||||||
addFatigueCards,
|
|
||||||
discardHand,
|
|
||||||
discardCard,
|
|
||||||
exhaustCard,
|
|
||||||
getEnemyCurrentIntent,
|
|
||||||
advanceEnemyIntent,
|
|
||||||
getEffectTiming,
|
|
||||||
getEffectData,
|
|
||||||
INITIAL_HAND_SIZE,
|
|
||||||
DEFAULT_MAX_ENERGY,
|
|
||||||
FATIGUE_CARDS_PER_SHUFFLE,
|
|
||||||
} from "./state";
|
|
||||||
|
|
||||||
export {
|
|
||||||
applyDamage,
|
|
||||||
applyDefend,
|
|
||||||
applyBuff,
|
|
||||||
removeBuff,
|
|
||||||
updateBuffs,
|
|
||||||
resolveEffect,
|
|
||||||
resolveCardEffects,
|
|
||||||
getModifiedAttackDamage,
|
|
||||||
getModifiedDefendAmount,
|
|
||||||
canPlayCard,
|
|
||||||
playCard,
|
|
||||||
areAllEnemiesDead,
|
|
||||||
isPlayerDead,
|
|
||||||
} from "./effects";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
TriggerContext,
|
|
||||||
BuffTriggerBehavior,
|
|
||||||
CombatTriggerRegistry,
|
|
||||||
TriggerEvent,
|
|
||||||
} from "./triggers";
|
|
||||||
|
|
||||||
export {
|
|
||||||
createCombatTriggerRegistry,
|
|
||||||
dispatchTrigger,
|
|
||||||
dispatchAttackedTrigger,
|
|
||||||
dispatchDamageTrigger,
|
|
||||||
dispatchOutgoingDamageTrigger,
|
|
||||||
dispatchIncomingDamageTrigger,
|
|
||||||
dispatchShuffleTrigger,
|
|
||||||
} from "./triggers";
|
|
||||||
|
|
||||||
export { prompts } from "./prompts";
|
|
||||||
|
|
||||||
export { runCombat } from "./procedure";
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
import type { IGameContext } from "@/core/game";
|
|
||||||
import type { CombatState, CombatResult, CombatGameContext, CombatEffectEntry } from "./types";
|
|
||||||
import type { CombatTriggerRegistry, TriggerContext } from "./triggers";
|
|
||||||
import { createCombatTriggerRegistry, dispatchTrigger, dispatchShuffleTrigger, dispatchOutgoingDamageTrigger, dispatchIncomingDamageTrigger, dispatchDamageTrigger, dispatchCardDrawnTrigger } from "./triggers";
|
|
||||||
import { prompts } from "./prompts";
|
|
||||||
import {
|
|
||||||
drawCardsToHand,
|
|
||||||
addFatigueCards,
|
|
||||||
discardHand,
|
|
||||||
discardCard,
|
|
||||||
getEnemyCurrentIntent,
|
|
||||||
advanceEnemyIntent,
|
|
||||||
DEFAULT_MAX_ENERGY,
|
|
||||||
FATIGUE_CARDS_PER_SHUFFLE,
|
|
||||||
} from "./state";
|
|
||||||
import {
|
|
||||||
applyDamage,
|
|
||||||
applyDefend,
|
|
||||||
updateBuffs,
|
|
||||||
canPlayCard,
|
|
||||||
playCard,
|
|
||||||
areAllEnemiesDead,
|
|
||||||
isPlayerDead,
|
|
||||||
resolveCardEffects,
|
|
||||||
removeBuff,
|
|
||||||
} from "./effects";
|
|
||||||
|
|
||||||
export async function runCombat(
|
|
||||||
game: CombatGameContext,
|
|
||||||
): Promise<CombatResult> {
|
|
||||||
const triggerRegistry = createCombatTriggerRegistry();
|
|
||||||
|
|
||||||
await game.produceAsync(async (state) => {
|
|
||||||
state.phase = "playerTurn";
|
|
||||||
state.player.energy = state.player.maxEnergy;
|
|
||||||
state.player.damageTakenThisTurn = 0;
|
|
||||||
state.player.damagedThisTurn = false;
|
|
||||||
state.player.cardsDiscardedThisTurn = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const currentState = game.value;
|
|
||||||
|
|
||||||
if (currentState.result) {
|
|
||||||
return currentState.result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentState.phase === "playerTurn") {
|
|
||||||
await runPlayerTurn(game, triggerRegistry);
|
|
||||||
} else if (currentState.phase === "enemyTurn") {
|
|
||||||
await runEnemyTurn(game, triggerRegistry);
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlayerDead(game.value)) {
|
|
||||||
await game.produceAsync((state) => {
|
|
||||||
state.result = "defeat";
|
|
||||||
state.phase = "combatEnd";
|
|
||||||
});
|
|
||||||
return "defeat";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (areAllEnemiesDead(game.value)) {
|
|
||||||
await game.produceAsync((state) => {
|
|
||||||
state.result = "victory";
|
|
||||||
state.phase = "combatEnd";
|
|
||||||
state.loot = generateLoot(state);
|
|
||||||
});
|
|
||||||
return "victory";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return game.value.result ?? "defeat";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runPlayerTurn(
|
|
||||||
game: CombatGameContext,
|
|
||||||
triggerRegistry: CombatTriggerRegistry,
|
|
||||||
): Promise<void> {
|
|
||||||
const triggerCtx = createTriggerContext(game);
|
|
||||||
|
|
||||||
await game.produceAsync(async (state) => {
|
|
||||||
updateBuffs(state.player.buffs);
|
|
||||||
state.player.damageTakenThisTurn = 0;
|
|
||||||
state.player.damagedThisTurn = false;
|
|
||||||
state.player.cardsDiscardedThisTurn = 0;
|
|
||||||
|
|
||||||
dispatchTrigger(triggerCtx, "onTurnStart", "player", triggerRegistry);
|
|
||||||
});
|
|
||||||
|
|
||||||
while (game.value.phase === "playerTurn") {
|
|
||||||
const action = await game.prompt<{ action: "play" | "end"; cardId?: string; targetId?: string }>(
|
|
||||||
prompts.playCard,
|
|
||||||
(cardId, targetId) => {
|
|
||||||
const state = game.value;
|
|
||||||
if (!cardId) throw "请选择卡牌";
|
|
||||||
|
|
||||||
const check = canPlayCard(state, cardId);
|
|
||||||
if (!check.canPlay) throw check.reason ?? "无法打出";
|
|
||||||
|
|
||||||
const card = state.player.deck.cards[cardId];
|
|
||||||
if (card?.itemData?.targetType === "single") {
|
|
||||||
const aliveEnemies = state.enemyOrder.filter((id) => state.enemies[id].isAlive);
|
|
||||||
if (!targetId && aliveEnemies.length > 0) {
|
|
||||||
throw "请指定目标";
|
|
||||||
}
|
|
||||||
if (targetId && !state.enemies[targetId]?.isAlive) {
|
|
||||||
throw "目标无效";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { action: "play" as const, cardId, targetId };
|
|
||||||
},
|
|
||||||
"player"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (action.action === "play" && action.cardId) {
|
|
||||||
const ctx = createEffectContext(game);
|
|
||||||
await game.produceAsync(async (state) => {
|
|
||||||
playCard({ state, rng: game._rng }, action.cardId!, action.targetId);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (areAllEnemiesDead(game.value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isPlayerDead(game.value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
await game.prompt<{ action: "end" }>(
|
|
||||||
prompts.endTurn,
|
|
||||||
() => {
|
|
||||||
return { action: "end" as const };
|
|
||||||
},
|
|
||||||
"player"
|
|
||||||
);
|
|
||||||
|
|
||||||
dispatchTrigger(createTriggerContext(game), "onTurnEnd", "player", triggerRegistry);
|
|
||||||
|
|
||||||
await game.produceAsync(async (state) => {
|
|
||||||
for (const cardId of [...state.player.deck.hand]) {
|
|
||||||
state.player.cardsDiscardedThisTurn++;
|
|
||||||
}
|
|
||||||
discardHand(state.player.deck);
|
|
||||||
});
|
|
||||||
|
|
||||||
await game.produceAsync(async (state) => {
|
|
||||||
if (state.player.deck.drawPile.length === 0) {
|
|
||||||
reshuffleWithFatigue(state);
|
|
||||||
dispatchShuffleTrigger(createTriggerContext(game), triggerRegistry);
|
|
||||||
}
|
|
||||||
const drawn = drawCardsToHand(state.player.deck, 5);
|
|
||||||
for (const cardId of drawn) {
|
|
||||||
dispatchCardDrawnTrigger(createTriggerContext(game), cardId, triggerRegistry);
|
|
||||||
}
|
|
||||||
state.player.energy = state.player.maxEnergy;
|
|
||||||
});
|
|
||||||
|
|
||||||
await game.produceAsync(async (state) => {
|
|
||||||
state.phase = "enemyTurn";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runEnemyTurn(
|
|
||||||
game: CombatGameContext,
|
|
||||||
triggerRegistry: CombatTriggerRegistry,
|
|
||||||
): Promise<void> {
|
|
||||||
const state = game.value;
|
|
||||||
|
|
||||||
await game.produceAsync(async (state) => {
|
|
||||||
for (const enemyId of state.enemyOrder) {
|
|
||||||
const enemy = state.enemies[enemyId];
|
|
||||||
if (!enemy.isAlive) continue;
|
|
||||||
updateBuffs(enemy.buffs);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const triggerCtx = createTriggerContext(game);
|
|
||||||
for (const enemyId of game.value.enemyOrder) {
|
|
||||||
const enemy = game.value.enemies[enemyId];
|
|
||||||
if (!enemy.isAlive) continue;
|
|
||||||
dispatchTrigger(triggerCtx, "onTurnStart", enemyId, triggerRegistry);
|
|
||||||
}
|
|
||||||
|
|
||||||
await game.produceAsync(async (state) => {
|
|
||||||
for (const enemyId of state.enemyOrder) {
|
|
||||||
const enemy = state.enemies[enemyId];
|
|
||||||
if (!enemy.isAlive) continue;
|
|
||||||
|
|
||||||
const intent = getEnemyCurrentIntent(enemy);
|
|
||||||
if (!intent) continue;
|
|
||||||
|
|
||||||
const effects = intent.effects as unknown as CombatEffectEntry[];
|
|
||||||
for (const entry of effects) {
|
|
||||||
const [target, effect, stacks] = entry;
|
|
||||||
|
|
||||||
if (effect.id === "attack") {
|
|
||||||
let damage = stacks;
|
|
||||||
damage = dispatchOutgoingDamageTrigger(createTriggerContext(game), enemyId, damage, triggerRegistry);
|
|
||||||
damage = dispatchIncomingDamageTrigger(createTriggerContext(game), "player", damage, triggerRegistry);
|
|
||||||
|
|
||||||
const result = applyDamage(state, "player", damage, enemyId);
|
|
||||||
if (result.damageDealt > 0) {
|
|
||||||
dispatchDamageTrigger(createTriggerContext(game), "player", result.damageDealt, triggerRegistry);
|
|
||||||
}
|
|
||||||
} else if (effect.id === "defend") {
|
|
||||||
if (target === "self") {
|
|
||||||
applyDefend(enemy.buffs, stacks);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
resolveEnemyEffect(state, enemyId, target, effect, stacks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
advanceEnemyIntent(enemy);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const enemyId of game.value.enemyOrder) {
|
|
||||||
const enemy = game.value.enemies[enemyId];
|
|
||||||
if (!enemy.isAlive) continue;
|
|
||||||
dispatchTrigger(createTriggerContext(game), "onTurnEnd", enemyId, triggerRegistry);
|
|
||||||
}
|
|
||||||
|
|
||||||
await game.produceAsync(async (state) => {
|
|
||||||
state.phase = "playerTurn";
|
|
||||||
state.turnNumber++;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveEnemyEffect(
|
|
||||||
state: CombatState,
|
|
||||||
enemyId: string,
|
|
||||||
target: string,
|
|
||||||
effect: { id: string; timing: string },
|
|
||||||
stacks: number,
|
|
||||||
): void {
|
|
||||||
switch (effect.id) {
|
|
||||||
case "spike":
|
|
||||||
case "venom":
|
|
||||||
case "curse":
|
|
||||||
case "aim":
|
|
||||||
case "roll":
|
|
||||||
case "vultureEye":
|
|
||||||
case "tailSting":
|
|
||||||
case "energyDrain":
|
|
||||||
case "molt":
|
|
||||||
case "storm":
|
|
||||||
case "static":
|
|
||||||
case "charge":
|
|
||||||
case "discard":
|
|
||||||
state.enemies[enemyId].buffs[effect.id] = (state.enemies[enemyId].buffs[effect.id] ?? 0) + stacks;
|
|
||||||
break;
|
|
||||||
case "summonMummy":
|
|
||||||
case "summonSandwormLarva":
|
|
||||||
case "reviveMummy":
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reshuffleWithFatigue(state: CombatState): void {
|
|
||||||
if (state.player.deck.discardPile.length === 0) return;
|
|
||||||
|
|
||||||
state.player.deck.drawPile.push(...state.player.deck.discardPile);
|
|
||||||
state.player.deck.discardPile = [];
|
|
||||||
|
|
||||||
addFatigueCards(state.player.deck, FATIGUE_CARDS_PER_SHUFFLE, { value: state.player.fatigueAddedCount });
|
|
||||||
state.player.fatigueAddedCount += FATIGUE_CARDS_PER_SHUFFLE;
|
|
||||||
|
|
||||||
for (let i = state.player.deck.drawPile.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[state.player.deck.drawPile[i], state.player.deck.drawPile[j]] = [state.player.deck.drawPile[j], state.player.deck.drawPile[i]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTriggerContext(game: CombatGameContext): TriggerContext {
|
|
||||||
return {
|
|
||||||
state: game.value,
|
|
||||||
rng: game._rng,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEffectContext(game: CombatGameContext) {
|
|
||||||
return {
|
|
||||||
state: game.value,
|
|
||||||
rng: game._rng,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateLoot(state: CombatState): CombatState["loot"] {
|
|
||||||
const loot: CombatState["loot"] = [];
|
|
||||||
let totalGold = 0;
|
|
||||||
for (const enemyId of state.enemyOrder) {
|
|
||||||
const enemy = state.enemies[enemyId];
|
|
||||||
totalGold += Math.floor(enemy.maxHp * 0.5);
|
|
||||||
}
|
|
||||||
if (totalGold > 0) {
|
|
||||||
loot.push({ type: "gold", amount: totalGold });
|
|
||||||
}
|
|
||||||
return loot;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { createPromptDef } from "@/core/game";
|
|
||||||
|
|
||||||
export const prompts = {
|
|
||||||
playCard: createPromptDef<[string, string?]>(
|
|
||||||
"play-card <cardId:string> [targetId:string]",
|
|
||||||
"选择卡牌并指定目标"
|
|
||||||
),
|
|
||||||
endTurn: createPromptDef<[]>(
|
|
||||||
"end-turn",
|
|
||||||
"结束回合"
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
@ -1,254 +0,0 @@
|
||||||
import type { GridInventory } from "../grid-inventory/types";
|
|
||||||
import type { GameItemMeta, PlayerState } from "../progress/types";
|
|
||||||
import type { PlayerDeck } from "../deck/types";
|
|
||||||
import type { EnemyDesert } from "../data/enemyDesert.csv";
|
|
||||||
import type { EffectDesert } from "../data/effectDesert.csv";
|
|
||||||
import type { EncounterDesert } from "../data/encounterDesert.csv";
|
|
||||||
import { generateDeckFromInventory, createStatusCard } from "../deck/factory";
|
|
||||||
import { enemyDesertData, effectDesertData, cardDesertData } from "../data";
|
|
||||||
import { createRNG } from "@/utils/rng";
|
|
||||||
import type {
|
|
||||||
BuffTable,
|
|
||||||
CombatState,
|
|
||||||
CombatPhase,
|
|
||||||
EnemyState,
|
|
||||||
PlayerCombatState,
|
|
||||||
ItemBuff,
|
|
||||||
LootEntry,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
const INITIAL_HAND_SIZE = 5;
|
|
||||||
const DEFAULT_MAX_ENERGY = 3;
|
|
||||||
const FATIGUE_CARDS_PER_SHUFFLE = 2;
|
|
||||||
|
|
||||||
export function createEnemyInstance(
|
|
||||||
templateId: string,
|
|
||||||
hp: number,
|
|
||||||
initBuffs: [EffectDesert, number][],
|
|
||||||
idCounter: { value: number },
|
|
||||||
): EnemyState {
|
|
||||||
idCounter.value++;
|
|
||||||
const id = `enemy-${idCounter.value}`;
|
|
||||||
const maxHp = hp;
|
|
||||||
const currentHp = hp;
|
|
||||||
|
|
||||||
const buffs: BuffTable = {};
|
|
||||||
for (const [effect, stacks] of initBuffs) {
|
|
||||||
buffs[effect.id] = (buffs[effect.id] ?? 0) + stacks;
|
|
||||||
}
|
|
||||||
|
|
||||||
const intentData = buildIntentLookup(templateId);
|
|
||||||
const initialIntent = findInitialIntent(templateId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
templateId,
|
|
||||||
hp: currentHp,
|
|
||||||
maxHp,
|
|
||||||
buffs,
|
|
||||||
currentIntentId: initialIntent ?? "",
|
|
||||||
intentData,
|
|
||||||
isAlive: true,
|
|
||||||
hadDefendBroken: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function findInitialIntent(enemyTemplateId: string): string | undefined {
|
|
||||||
for (const row of enemyDesertData) {
|
|
||||||
if (row.enemy === enemyTemplateId && row.initialIntent) {
|
|
||||||
return row.intentId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildIntentLookup(enemyTemplateId: string): Record<string, EnemyDesert> {
|
|
||||||
const lookup: Record<string, EnemyDesert> = {};
|
|
||||||
for (const row of enemyDesertData) {
|
|
||||||
if (row.enemy === enemyTemplateId) {
|
|
||||||
lookup[row.intentId] = row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lookup;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPlayerCombatState(
|
|
||||||
playerState: PlayerState,
|
|
||||||
inventory: GridInventory<GameItemMeta>,
|
|
||||||
): PlayerCombatState {
|
|
||||||
const deck = generateDeckFromInventory(inventory);
|
|
||||||
return {
|
|
||||||
hp: playerState.currentHp,
|
|
||||||
maxHp: playerState.maxHp,
|
|
||||||
energy: DEFAULT_MAX_ENERGY,
|
|
||||||
maxEnergy: DEFAULT_MAX_ENERGY,
|
|
||||||
buffs: {},
|
|
||||||
deck,
|
|
||||||
damageTakenThisTurn: 0,
|
|
||||||
damagedThisTurn: false,
|
|
||||||
cardsDiscardedThisTurn: 0,
|
|
||||||
itemBuffs: [],
|
|
||||||
fatigueAddedCount: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCombatState(
|
|
||||||
playerState: PlayerState,
|
|
||||||
inventory: GridInventory<GameItemMeta>,
|
|
||||||
encounter: EncounterDesert,
|
|
||||||
): CombatState {
|
|
||||||
const idCounter = { value: 0 };
|
|
||||||
const player = createPlayerCombatState(playerState, inventory);
|
|
||||||
|
|
||||||
const enemies: Record<string, EnemyState> = {};
|
|
||||||
const enemyOrder: string[] = [];
|
|
||||||
const enemyTemplateData: Record<string, EnemyDesert> = {};
|
|
||||||
|
|
||||||
for (const enemyEntry of encounter.enemies as unknown as [string, number, number][]) {
|
|
||||||
const [enemyId, hp, bonusHp] = enemyEntry;
|
|
||||||
// Find initBuffs from enemyDesert (first row for this enemy type)
|
|
||||||
const enemyRow = enemyDesertData.find((e) => e.enemy === enemyId);
|
|
||||||
const initBuffs: [EffectDesert, number][] = [];
|
|
||||||
if (enemyRow) {
|
|
||||||
for (const [effect, stacks] of enemyRow.initBuffs) {
|
|
||||||
initBuffs.push([effect, stacks]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalHp = hp + bonusHp;
|
|
||||||
const enemyInstance = createEnemyInstance(
|
|
||||||
enemyId,
|
|
||||||
totalHp,
|
|
||||||
initBuffs,
|
|
||||||
idCounter,
|
|
||||||
);
|
|
||||||
enemies[enemyInstance.id] = enemyInstance;
|
|
||||||
enemyOrder.push(enemyInstance.id);
|
|
||||||
enemyTemplateData[enemyInstance.templateId] = enemyRow!;
|
|
||||||
}
|
|
||||||
|
|
||||||
shuffleDeck(player.deck.drawPile, createRNG(0));
|
|
||||||
|
|
||||||
drawCardsToHand(player.deck, INITIAL_HAND_SIZE);
|
|
||||||
|
|
||||||
return {
|
|
||||||
enemies,
|
|
||||||
enemyOrder,
|
|
||||||
player,
|
|
||||||
phase: "playerTurn" as CombatPhase,
|
|
||||||
turnNumber: 1,
|
|
||||||
result: null,
|
|
||||||
loot: [],
|
|
||||||
enemyTemplateData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function drawCardsToHand(deck: PlayerDeck, count: number): string[] {
|
|
||||||
const drawn: string[] = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
if (deck.drawPile.length === 0) {
|
|
||||||
reshuffleDiscardIntoDraw(deck);
|
|
||||||
}
|
|
||||||
if (deck.drawPile.length === 0) break;
|
|
||||||
|
|
||||||
const cardId = deck.drawPile.shift()!;
|
|
||||||
deck.hand.push(cardId);
|
|
||||||
drawn.push(cardId);
|
|
||||||
}
|
|
||||||
return drawn;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reshuffleDiscardIntoDraw(deck: PlayerDeck): void {
|
|
||||||
if (deck.discardPile.length === 0) return;
|
|
||||||
|
|
||||||
deck.drawPile.push(...deck.discardPile);
|
|
||||||
deck.discardPile = [];
|
|
||||||
|
|
||||||
for (let i = deck.drawPile.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[deck.drawPile[i], deck.drawPile[j]] = [deck.drawPile[j], deck.drawPile[i]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addFatigueCards(deck: PlayerDeck, count: number, fatigueCounter: { value: number }): number {
|
|
||||||
let added = 0;
|
|
||||||
const fatigueDef = cardDesertData.find((c) => c.id === "fatigue");
|
|
||||||
if (!fatigueDef) return 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
fatigueCounter.value++;
|
|
||||||
const card = createStatusCard(
|
|
||||||
`fatigue-${fatigueCounter.value}`,
|
|
||||||
fatigueDef.name,
|
|
||||||
fatigueDef.desc,
|
|
||||||
);
|
|
||||||
deck.cards[card.id] = card;
|
|
||||||
deck.drawPile.push(card.id);
|
|
||||||
added++;
|
|
||||||
}
|
|
||||||
return added;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function discardHand(deck: PlayerDeck): void {
|
|
||||||
const handCards = [...deck.hand];
|
|
||||||
deck.discardPile.push(...handCards);
|
|
||||||
deck.hand = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function discardCard(deck: PlayerDeck, cardId: string): void {
|
|
||||||
const handIdx = deck.hand.indexOf(cardId);
|
|
||||||
if (handIdx >= 0) {
|
|
||||||
deck.hand.splice(handIdx, 1);
|
|
||||||
deck.discardPile.push(cardId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function exhaustCard(deck: PlayerDeck, cardId: string): void {
|
|
||||||
const handIdx = deck.hand.indexOf(cardId);
|
|
||||||
if (handIdx >= 0) {
|
|
||||||
deck.hand.splice(handIdx, 1);
|
|
||||||
deck.exhaustPile.push(cardId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEnemyCurrentIntent(enemy: EnemyState): EnemyDesert | undefined {
|
|
||||||
return enemy.intentData[enemy.currentIntentId];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advanceEnemyIntent(enemy: EnemyState): void {
|
|
||||||
const current = getEnemyCurrentIntent(enemy);
|
|
||||||
if (!current) return;
|
|
||||||
|
|
||||||
if (enemy.hadDefendBroken && current.brokenIntent.length > 0) {
|
|
||||||
const idx = Math.floor(Math.random() * current.brokenIntent.length);
|
|
||||||
enemy.currentIntentId = current.brokenIntent[idx];
|
|
||||||
enemy.hadDefendBroken = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.nextIntents.length > 0) {
|
|
||||||
const idx = Math.floor(Math.random() * current.nextIntents.length);
|
|
||||||
enemy.currentIntentId = current.nextIntents[idx];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
enemy.currentIntentId = current.intentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shuffleDeck(drawPile: string[], rng: { nextInt: (n: number) => number }): void {
|
|
||||||
for (let i = drawPile.length - 1; i > 0; i--) {
|
|
||||||
const j = rng.nextInt(i + 1);
|
|
||||||
[drawPile[i], drawPile[j]] = [drawPile[j], drawPile[i]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEffectTiming(effectId: string): EffectDesert["timing"] | undefined {
|
|
||||||
const effect = effectDesertData.find(e => e.id === effectId);
|
|
||||||
return effect?.timing;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEffectData(effectId: string): EffectDesert | undefined {
|
|
||||||
return effectDesertData.find(e => e.id === effectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { INITIAL_HAND_SIZE, DEFAULT_MAX_ENERGY, FATIGUE_CARDS_PER_SHUFFLE };
|
|
||||||
|
|
@ -1,347 +0,0 @@
|
||||||
import type { EffectDesert } from "../data/effectDesert.csv";
|
|
||||||
import { cardDesertData } from "../data";
|
|
||||||
import { createStatusCard } from "../deck/factory";
|
|
||||||
import type { BuffTable, CombatEffectEntry, CombatState } from "./types";
|
|
||||||
import { applyDamage, removeBuff } from "./effects";
|
|
||||||
|
|
||||||
export type TriggerContext = {
|
|
||||||
state: CombatState;
|
|
||||||
rng: { nextInt: (n: number) => number };
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BuffTriggerBehavior = {
|
|
||||||
onTurnStart?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void;
|
|
||||||
onTurnEnd?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void;
|
|
||||||
onAttacked?: (ctx: TriggerContext, attackerKey: "player" | string, defenderKey: "player" | string, damage: number, stacks: number) => number;
|
|
||||||
onDamage?: (ctx: TriggerContext, targetKey: "player" | string, damage: number, stacks: number) => void;
|
|
||||||
modifyOutgoingDamage?: (ctx: TriggerContext, sourceKey: "player" | string, damage: number, stacks: number) => number;
|
|
||||||
modifyIncomingDamage?: (ctx: TriggerContext, targetKey: "player" | string, damage: number, stacks: number) => number;
|
|
||||||
onShuffle?: (ctx: TriggerContext, stacks: number) => void;
|
|
||||||
onCardPlayed?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
|
|
||||||
onCardDiscarded?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
|
|
||||||
onCardDrawn?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TriggerEvent =
|
|
||||||
| "onTurnStart"
|
|
||||||
| "onTurnEnd"
|
|
||||||
| "onAttacked"
|
|
||||||
| "onDamage"
|
|
||||||
| "modifyOutgoingDamage"
|
|
||||||
| "modifyIncomingDamage"
|
|
||||||
| "onShuffle"
|
|
||||||
| "onCardPlayed"
|
|
||||||
| "onCardDiscarded"
|
|
||||||
| "onCardDrawn";
|
|
||||||
|
|
||||||
export type CombatTriggerRegistry = Record<string, BuffTriggerBehavior>;
|
|
||||||
|
|
||||||
export function createCombatTriggerRegistry(): CombatTriggerRegistry {
|
|
||||||
return {
|
|
||||||
spike: {
|
|
||||||
onAttacked(ctx, attackerKey, _defenderKey, damage, stacks) {
|
|
||||||
const { state } = ctx;
|
|
||||||
applyDamage(state, attackerKey, stacks, _defenderKey);
|
|
||||||
return damage;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
aim: {
|
|
||||||
modifyOutgoingDamage(_ctx, _sourceKey, damage, stacks) {
|
|
||||||
if (stacks > 0) return damage * 2;
|
|
||||||
return damage;
|
|
||||||
},
|
|
||||||
onDamage(ctx, targetKey, damage, stacks) {
|
|
||||||
const { state } = ctx;
|
|
||||||
const entity = targetKey === "player" ? null : state.enemies[targetKey];
|
|
||||||
if (entity) {
|
|
||||||
const loss = Math.min(stacks, damage);
|
|
||||||
removeBuff(entity.buffs, "aim", loss);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
charge: {
|
|
||||||
modifyOutgoingDamage(_ctx, _sourceKey, damage, stacks) {
|
|
||||||
if (stacks > 0) {
|
|
||||||
return damage * 2;
|
|
||||||
}
|
|
||||||
return damage;
|
|
||||||
},
|
|
||||||
modifyIncomingDamage(_ctx, _targetKey, damage, stacks) {
|
|
||||||
if (stacks > 0) {
|
|
||||||
return damage * 2;
|
|
||||||
}
|
|
||||||
return damage;
|
|
||||||
},
|
|
||||||
onDamage(ctx, targetKey, damage, stacks) {
|
|
||||||
const { state } = ctx;
|
|
||||||
const entity = targetKey === "player"
|
|
||||||
? { buffs: state.player.buffs } as { buffs: BuffTable }
|
|
||||||
: state.enemies[targetKey];
|
|
||||||
if (entity) {
|
|
||||||
const loss = Math.min(stacks, damage);
|
|
||||||
removeBuff(entity.buffs, "charge", loss);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
roll: {
|
|
||||||
modifyOutgoingDamage(ctx, sourceKey, damage, stacks) {
|
|
||||||
if (stacks >= 10) {
|
|
||||||
const { state } = ctx;
|
|
||||||
const entity = sourceKey === "player"
|
|
||||||
? { buffs: state.player.buffs } as { buffs: BuffTable }
|
|
||||||
: state.enemies[sourceKey];
|
|
||||||
if (entity) {
|
|
||||||
const spendable = Math.floor(stacks / 10) * 10;
|
|
||||||
const bonusDamage = Math.floor(spendable / 10);
|
|
||||||
removeBuff(entity.buffs, "roll", spendable);
|
|
||||||
return damage + bonusDamage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return damage;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tailSting: {
|
|
||||||
onTurnEnd(ctx, entityKey, stacks) {
|
|
||||||
const { state } = ctx;
|
|
||||||
if (entityKey !== "player" && state.enemies[entityKey]?.isAlive) {
|
|
||||||
applyDamage(state, "player", stacks, entityKey);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
energyDrain: {
|
|
||||||
onDamage(ctx, targetKey, _damage, _stacks) {
|
|
||||||
const { state } = ctx;
|
|
||||||
if (targetKey === "player" && state.player.damagedThisTurn === false) {
|
|
||||||
// This is the first damage; mark it.
|
|
||||||
// actual energy drain happens in onTurnStart check
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onTurnStart(ctx, entityKey, _stacks) {
|
|
||||||
// energyDrain: first damage each turn loses 1 energy
|
|
||||||
// We just mark that the enemy has this; actual drain is in onDamage
|
|
||||||
},
|
|
||||||
},
|
|
||||||
molt: {
|
|
||||||
onDamage(ctx, targetKey, _damage, _stacks) {
|
|
||||||
const { state } = ctx;
|
|
||||||
if (targetKey !== "player") {
|
|
||||||
const enemy = state.enemies[targetKey];
|
|
||||||
if (enemy && enemy.isAlive) {
|
|
||||||
const moltStacks = enemy.buffs["molt"] ?? 0;
|
|
||||||
if (moltStacks >= enemy.maxHp) {
|
|
||||||
enemy.isAlive = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
storm: {
|
|
||||||
onAttacked(ctx, attackerKey, defenderKey, damage, stacks) {
|
|
||||||
const { state } = ctx;
|
|
||||||
if (defenderKey !== "player" && state.enemies[defenderKey]?.isAlive) {
|
|
||||||
addStatusCardToHand(state, "static", 1);
|
|
||||||
}
|
|
||||||
return damage;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vultureEye: {
|
|
||||||
onDamage(ctx, targetKey, damage, stacks) {
|
|
||||||
const { state } = ctx;
|
|
||||||
if (targetKey === "player" && damage > 0) {
|
|
||||||
const vultureEnemies = state.enemyOrder.filter(
|
|
||||||
(id) => state.enemies[id].isAlive && state.enemies[id].buffs["vultureEye"] && state.enemies[id].templateId === "秃鹫"
|
|
||||||
);
|
|
||||||
if (vultureEnemies.length > 0) {
|
|
||||||
for (const vultureId of vultureEnemies) {
|
|
||||||
const vulture = state.enemies[vultureId];
|
|
||||||
const intent = vulture.intentData["attack"];
|
|
||||||
if (intent) {
|
|
||||||
const effects = intent.effects as unknown as CombatEffectEntry[];
|
|
||||||
for (const entry of effects) {
|
|
||||||
if (entry[0] === "player" && entry[1].id === "attack") {
|
|
||||||
applyDamage(state, "player", entry[2], vultureId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
venom: {
|
|
||||||
onCardDiscarded(ctx, cardId, stacks) {
|
|
||||||
const { state } = ctx;
|
|
||||||
state.player.cardsDiscardedThisTurn++;
|
|
||||||
const venomCards = state.player.deck.hand.filter((id) => {
|
|
||||||
const card = state.player.deck.cards[id];
|
|
||||||
return card && card.itemData === null && card.displayName === "蛇毒";
|
|
||||||
});
|
|
||||||
if (state.player.cardsDiscardedThisTurn > 1 && venomCards.length > 0) {
|
|
||||||
applyDamage(state, "player", 6, undefined);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
static: {
|
|
||||||
modifyIncomingDamage(_ctx, targetKey, damage, stacks) {
|
|
||||||
if (targetKey === "player") {
|
|
||||||
return damage + stacks;
|
|
||||||
}
|
|
||||||
return damage;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
discard: {
|
|
||||||
onShuffle(ctx, stacks) {
|
|
||||||
// Bandit: shuffle discards random item cards
|
|
||||||
// Simplified: mark the effect for the procedure to handle
|
|
||||||
},
|
|
||||||
},
|
|
||||||
curse: {
|
|
||||||
onDamage(ctx, targetKey, damage, stacks) {
|
|
||||||
// Curse: when attacked, item attack -1 until card from that item is discarded
|
|
||||||
// This is handled via itemBuffs in effects
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function addStatusCardToHand(state: CombatState, effectId: string, count: number): void {
|
|
||||||
const cardDef = cardDesertData.find((c) => c.id === effectId);
|
|
||||||
if (!cardDef) return;
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const cardId = `status-${effectId}-${Date.now()}-${i}`;
|
|
||||||
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
|
|
||||||
state.player.deck.cards[card.id] = card;
|
|
||||||
state.player.deck.hand.push(card.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispatchTrigger(
|
|
||||||
ctx: TriggerContext,
|
|
||||||
event: TriggerEvent,
|
|
||||||
entityKey: "player" | string,
|
|
||||||
registry: CombatTriggerRegistry,
|
|
||||||
): void {
|
|
||||||
const buffs = entityKey === "player"
|
|
||||||
? ctx.state.player.buffs
|
|
||||||
: ctx.state.enemies[entityKey]?.buffs;
|
|
||||||
if (!buffs) return;
|
|
||||||
|
|
||||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
|
||||||
const behavior = registry[buffId];
|
|
||||||
if (!behavior) continue;
|
|
||||||
const handler = behavior[event];
|
|
||||||
if (handler) {
|
|
||||||
handler(ctx, entityKey, stacks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispatchAttackedTrigger(
|
|
||||||
ctx: TriggerContext,
|
|
||||||
attackerKey: "player" | string,
|
|
||||||
defenderKey: "player" | string,
|
|
||||||
damage: number,
|
|
||||||
registry: CombatTriggerRegistry,
|
|
||||||
): number {
|
|
||||||
const buffs = defenderKey === "player"
|
|
||||||
? ctx.state.player.buffs
|
|
||||||
: ctx.state.enemies[defenderKey]?.buffs;
|
|
||||||
if (!buffs) return damage;
|
|
||||||
|
|
||||||
let modifiedDamage = damage;
|
|
||||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
|
||||||
const behavior = registry[buffId];
|
|
||||||
if (!behavior?.onAttacked) continue;
|
|
||||||
modifiedDamage = behavior.onAttacked(ctx, attackerKey, defenderKey, modifiedDamage, stacks);
|
|
||||||
}
|
|
||||||
return modifiedDamage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispatchDamageTrigger(
|
|
||||||
ctx: TriggerContext,
|
|
||||||
targetKey: "player" | string,
|
|
||||||
damage: number,
|
|
||||||
registry: CombatTriggerRegistry,
|
|
||||||
): void {
|
|
||||||
const buffs = targetKey === "player"
|
|
||||||
? ctx.state.player.buffs
|
|
||||||
: ctx.state.enemies[targetKey]?.buffs;
|
|
||||||
if (!buffs) return;
|
|
||||||
|
|
||||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
|
||||||
const behavior = registry[buffId];
|
|
||||||
if (!behavior?.onDamage) continue;
|
|
||||||
behavior.onDamage(ctx, targetKey, damage, stacks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispatchOutgoingDamageTrigger(
|
|
||||||
ctx: TriggerContext,
|
|
||||||
sourceKey: "player" | string,
|
|
||||||
damage: number,
|
|
||||||
registry: CombatTriggerRegistry,
|
|
||||||
): number {
|
|
||||||
const buffs = sourceKey === "player"
|
|
||||||
? ctx.state.player.buffs
|
|
||||||
: ctx.state.enemies[sourceKey]?.buffs;
|
|
||||||
if (!buffs) return damage;
|
|
||||||
|
|
||||||
let modifiedDamage = damage;
|
|
||||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
|
||||||
const behavior = registry[buffId];
|
|
||||||
if (!behavior?.modifyOutgoingDamage) continue;
|
|
||||||
modifiedDamage = behavior.modifyOutgoingDamage(ctx, sourceKey, modifiedDamage, stacks);
|
|
||||||
}
|
|
||||||
return modifiedDamage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispatchIncomingDamageTrigger(
|
|
||||||
ctx: TriggerContext,
|
|
||||||
targetKey: "player" | string,
|
|
||||||
damage: number,
|
|
||||||
registry: CombatTriggerRegistry,
|
|
||||||
): number {
|
|
||||||
const buffs = targetKey === "player"
|
|
||||||
? ctx.state.player.buffs
|
|
||||||
: ctx.state.enemies[targetKey]?.buffs;
|
|
||||||
if (!buffs) return damage;
|
|
||||||
|
|
||||||
let modifiedDamage = damage;
|
|
||||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
|
||||||
const behavior = registry[buffId];
|
|
||||||
if (!behavior?.modifyIncomingDamage) continue;
|
|
||||||
modifiedDamage = behavior.modifyIncomingDamage(ctx, targetKey, modifiedDamage, stacks);
|
|
||||||
}
|
|
||||||
return modifiedDamage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispatchShuffleTrigger(
|
|
||||||
ctx: TriggerContext,
|
|
||||||
registry: CombatTriggerRegistry,
|
|
||||||
): void {
|
|
||||||
for (const enemyId of ctx.state.enemyOrder) {
|
|
||||||
const enemy = ctx.state.enemies[enemyId];
|
|
||||||
if (!enemy.isAlive) continue;
|
|
||||||
for (const [buffId, stacks] of Object.entries(enemy.buffs)) {
|
|
||||||
const behavior = registry[buffId];
|
|
||||||
if (!behavior?.onShuffle) continue;
|
|
||||||
behavior.onShuffle(ctx, stacks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispatchCardDrawnTrigger(
|
|
||||||
ctx: TriggerContext,
|
|
||||||
cardId: string,
|
|
||||||
registry: CombatTriggerRegistry,
|
|
||||||
): void {
|
|
||||||
const buffs = ctx.state.player.buffs;
|
|
||||||
if (!buffs) return;
|
|
||||||
|
|
||||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
|
||||||
const behavior = registry[buffId];
|
|
||||||
if (!behavior?.onCardDrawn) continue;
|
|
||||||
behavior.onCardDrawn(ctx, cardId, stacks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import type { EnemyDesert } from "../data/enemyDesert.csv";
|
|
||||||
import type { EffectDesert } from "../data/effectDesert.csv";
|
|
||||||
import type { PlayerDeck, GameCard } from "../deck/types";
|
|
||||||
import type { PlayerState } from "../progress/types";
|
|
||||||
|
|
||||||
export type BuffTable = Record<string, number>;
|
|
||||||
|
|
||||||
/** Lifecycle timing for effects - matches CSV timing column */
|
|
||||||
export type EffectTiming = EffectDesert["timing"];
|
|
||||||
|
|
||||||
export type EffectTarget = "self" | "target" | "all" | "random" | "player" | "team";
|
|
||||||
|
|
||||||
export type ItemBuff = {
|
|
||||||
effectId: string;
|
|
||||||
stacks: number;
|
|
||||||
timing: EffectTiming;
|
|
||||||
sourceItemId: string;
|
|
||||||
targetItemId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EnemyState = {
|
|
||||||
id: string;
|
|
||||||
templateId: string;
|
|
||||||
hp: number;
|
|
||||||
maxHp: number;
|
|
||||||
buffs: BuffTable;
|
|
||||||
currentIntentId: string;
|
|
||||||
intentData: Record<string, EnemyDesert>;
|
|
||||||
isAlive: boolean;
|
|
||||||
hadDefendBroken: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PlayerCombatState = {
|
|
||||||
hp: number;
|
|
||||||
maxHp: number;
|
|
||||||
energy: number;
|
|
||||||
maxEnergy: number;
|
|
||||||
buffs: BuffTable;
|
|
||||||
deck: PlayerDeck;
|
|
||||||
damageTakenThisTurn: number;
|
|
||||||
damagedThisTurn: boolean;
|
|
||||||
cardsDiscardedThisTurn: number;
|
|
||||||
itemBuffs: ItemBuff[];
|
|
||||||
fatigueAddedCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
|
|
||||||
|
|
||||||
export type CombatResult = "victory" | "defeat";
|
|
||||||
|
|
||||||
export type LootEntry = {
|
|
||||||
type: "gold" | "item" | "relic";
|
|
||||||
amount?: number;
|
|
||||||
itemId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CombatState = {
|
|
||||||
enemies: Record<string, EnemyState>;
|
|
||||||
enemyOrder: string[];
|
|
||||||
player: PlayerCombatState;
|
|
||||||
phase: CombatPhase;
|
|
||||||
turnNumber: number;
|
|
||||||
result: CombatResult | null;
|
|
||||||
loot: LootEntry[];
|
|
||||||
enemyTemplateData: Record<string, EnemyDesert>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CombatEffectEntry = [EffectTarget, EffectDesert, number];
|
|
||||||
|
|
||||||
export type CombatEntity = {
|
|
||||||
buffs: BuffTable;
|
|
||||||
hp: number;
|
|
||||||
maxHp: number;
|
|
||||||
isAlive: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CombatGameContext = import("@/core/game").IGameContext<CombatState>;
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
# cardDesert: unified card definitions for item cards and status cards
|
|
||||||
# type: 'item' = inventory item card, 'status' = status effect card
|
|
||||||
# costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
|
|
||||||
# targetType: 'single' = target one enemy, 'none' = no target
|
|
||||||
# unplayable: true for status cards that cannot be played
|
|
||||||
# onPlay: effects triggered when card is played
|
|
||||||
# onDraw: effects triggered when card enters hand
|
|
||||||
# onDiscard: effects triggered when card is discarded
|
|
||||||
|
|
||||||
id,name,desc,type,costType,costCount,targetType,unplayable,onPlay,onDraw,onDiscard
|
|
||||||
string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none',boolean,['self'|'target'|'all'|'random'|'player';@effectDesert;number][],['self'|'target'|'all'|'random'|'player';@effectDesert;number][],['self'|'target'|'all'|'random'|'player';@effectDesert;number][]
|
|
||||||
sword,剑,【攻击2】【攻击2】,item,energy,1,single,false,[target;attack;2];[target;attack;2],,
|
|
||||||
greataxe,长斧,对全体【攻击5】,item,energy,2,none,false,[all;attack;5],,
|
|
||||||
spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single,false,[target;attack;2];[target;attack;2];[target;attack;2],,
|
|
||||||
dagger,短刀,【攻击3】【攻击3】,item,energy,1,single,false,[target;attack;3];[target;attack;3],,
|
|
||||||
dart,飞镖,【攻击1】抓一张牌,item,energy,0,single,false,[target;attack;1];[self;draw;1],,
|
|
||||||
crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single,false,[target;attack;6];[self;crossbow;0],,
|
|
||||||
shield,盾,【防御3】,item,energy,1,none,false,[self;defend;3],,
|
|
||||||
hat,斗笠,【防御8】,item,energy,2,none,false,[self;defend;8],,
|
|
||||||
cape,披风,【防御2】下回合【防御2】,item,energy,1,none,false,[self;defend;2];[self;defendNext;2],,
|
|
||||||
bracer,护腕,【防御1】抓1张牌,item,energy,0,none,false,[self;defend;1];[self;draw;1],,
|
|
||||||
greatshield,大盾,【防御5】,item,energy,1,none,false,[self;defend;5],,
|
|
||||||
chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none,false,[self;damageReduce;3],,
|
|
||||||
bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none,false,[self;removeWound;1],,
|
|
||||||
poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none,false,[self;attackBuff;2],,
|
|
||||||
fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none,false,[self;defendBuff;2],,
|
|
||||||
vitalityPotion,活力药剂,获得1点能量,item,uses,3,none,false,[self;gainEnergy;1],,
|
|
||||||
focusPotion,集中药剂,抓2张牌,item,uses,3,none,false,[self;draw;2],,
|
|
||||||
healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none,false,[self;removeWound;3],,
|
|
||||||
waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none,false,[self;energyNext;1];[self;drawNext;2],,
|
|
||||||
rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none,false,[self;defendBuffUntilPlay;2],,
|
|
||||||
belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none,false,[self;drawChoice;1],,
|
|
||||||
torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none,false,[self;burnForEnergy;1],,
|
|
||||||
whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none,false,[self;attackBuffUntilPlay;3],,
|
|
||||||
blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none,false,[self;transformRandom;1],,
|
|
||||||
wound,伤口,无效果占用手牌和牌堆,status,none,0,none,true,,,
|
|
||||||
venom,蛇毒,弃掉时受到3点伤害,status,none,0,none,true,,,[self;attack;3]
|
|
||||||
curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none,true,,[self;curse;1],
|
|
||||||
static,静电,在手里时受电击伤害+1,status,none,0,none,true,,[self;static;1],
|
|
||||||
fatigue,疲劳,占用手牌,status,none,0,none,true,,,
|
|
||||||
vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none,true,,[self;expose;3],
|
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
import type { EffectDesert } from './effectDesert.csv';
|
|
||||||
|
|
||||||
type CardDesertTable = readonly {
|
|
||||||
readonly id: string;
|
|
||||||
readonly name: string;
|
|
||||||
readonly desc: string;
|
|
||||||
readonly type: "item" | "status";
|
|
||||||
readonly costType: "energy" | "uses" | "none";
|
|
||||||
readonly costCount: number;
|
|
||||||
readonly targetType: "single" | "none";
|
|
||||||
readonly unplayable: boolean;
|
|
||||||
readonly onPlay: readonly ["self" | "target" | "all" | "random" | "player", EffectDesert, number];
|
|
||||||
readonly onDraw: readonly ["self" | "target" | "all" | "random" | "player", EffectDesert, number];
|
|
||||||
readonly onDiscard: readonly ["self" | "target" | "all" | "random" | "player", EffectDesert, number];
|
|
||||||
}[];
|
|
||||||
|
|
||||||
export type CardDesert = CardDesertTable[number];
|
|
||||||
|
|
||||||
declare function getData(): CardDesertTable;
|
|
||||||
export default getData;
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
# cardDesert: unified card definitions for item cards and status cards
|
||||||
|
# type: 'item' = inventory item card, 'status' = status effect card
|
||||||
|
# costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
|
||||||
|
# targetType: 'single' = target one enemy, 'none' = no target
|
||||||
|
# onPlay: effects triggered when card is played
|
||||||
|
# onDraw: effects triggered when card enters hand
|
||||||
|
# onDiscard: effects triggered when card is discarded
|
||||||
|
|
||||||
|
id,name,desc,type,costType,costCount,targetType,effects
|
||||||
|
string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none',['onPlay'|'onDraw'|'onDiscard';'self'|'target'|'all'|'random';@effect;number][]
|
||||||
|
sword,剑,【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2]
|
||||||
|
greataxe,长斧,对全体【攻击5】,item,energy,2,none,[onPlay;all;attack;5]
|
||||||
|
spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2];[onPlay;target;attack;2]
|
||||||
|
dagger,短刀,【攻击3】【攻击3】,item,energy,1,single,[onPlay;target;attack;3];[onPlay;target;attack;3]
|
||||||
|
dart,飞镖,【攻击1】抓一张牌,item,energy,0,single,[onPlay;target;attack;1];[onPlay;self;draw;1]
|
||||||
|
crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single,[onPlay;target;attack;6];[onPlay;self;crossbow;0]
|
||||||
|
shield,盾,【防御3】,item,energy,1,none,[onPlay;self;defend;3]
|
||||||
|
hat,斗笠,【防御8】,item,energy,2,none,[onPlay;self;defend;8]
|
||||||
|
cape,披风,【防御2】下回合【防御2】,item,energy,1,none,[onPlay;self;defend;2];[onPlay;self;defendNext;2]
|
||||||
|
bracer,护腕,【防御1】抓1张牌,item,energy,0,none,[onPlay;self;defend;1];[onPlay;self;draw;1]
|
||||||
|
greatshield,大盾,【防御5】,item,energy,1,none,[onPlay;self;defend;5]
|
||||||
|
chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none,[onPlay;self;damageReduce;3]
|
||||||
|
bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none,[onPlay;self;removeWound;1]
|
||||||
|
poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none,[onPlay;self;attackBuff;2]
|
||||||
|
fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none,[onPlay;self;defendBuff;2]
|
||||||
|
vitalityPotion,活力药剂,获得1点能量,item,uses,3,none,[onPlay;self;gainEnergy;1]
|
||||||
|
focusPotion,集中药剂,抓2张牌,item,uses,3,none,[onPlay;self;draw;2]
|
||||||
|
healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none,[onPlay;self;removeWound;3]
|
||||||
|
waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none,[onPlay;self;energyNext;1];[onPlay;self;drawNext;2]
|
||||||
|
rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none,[onPlay;self;defendBuffUntilPlay;2]
|
||||||
|
belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none,[onPlay;self;drawChoice;1]
|
||||||
|
torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none,[onPlay;self;burnForEnergy;1]
|
||||||
|
whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none,[onPlay;self;attackBuffUntilPlay;3]
|
||||||
|
blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none,[onPlay;self;transformRandom;1]
|
||||||
|
wound,伤口,无效果占用手牌和牌堆,status,none,0,none,
|
||||||
|
venom,蛇毒,弃掉时受到3点伤害,status,none,0,none,[onDiscard;self;attack;3]
|
||||||
|
curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none,[onDraw;self;curse;1]
|
||||||
|
static,静电,在手里时受电击伤害+1,status,none,0,none,[onDraw;self;static;1]
|
||||||
|
fatigue,疲劳,占用手牌,status,none,0,none,
|
||||||
|
vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none,[onDraw;self;expose;3]
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Effect } from './effect.csv';
|
||||||
|
|
||||||
|
type CardTable = readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly desc: string;
|
||||||
|
readonly type: "item" | "status";
|
||||||
|
readonly costType: "energy" | "uses" | "none";
|
||||||
|
readonly costCount: number;
|
||||||
|
readonly targetType: "single" | "none";
|
||||||
|
readonly effects: readonly ["onPlay" | "onDraw" | "onDiscard", "self" | "target" | "all" | "random", Effect, number];
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type Card = CardTable[number];
|
||||||
|
|
||||||
|
declare function getData(): CardTable;
|
||||||
|
export default getData;
|
||||||
|
|
@ -1,25 +1,27 @@
|
||||||
# instant: 不施加buff,瞬间生效
|
# instant: 不施加buff,瞬间生效
|
||||||
|
|
||||||
# temporary: 施加buff,下回合开始时失效
|
# temporary: 施加buff,下回合开始时失效
|
||||||
# lingering: 施加buff,下回合开始时失去1层
|
# lingering: 施加buff,下回合开始时失去1层
|
||||||
# permanent: 施加buff
|
# permanent: 施加buff
|
||||||
# posture: 施加buff,每受到1点伤害移除1层
|
# posture: 施加buff,每受到1点伤害移除1层
|
||||||
# card: 不施加buff,对玩家时在玩家弃牌堆创建同名卡牌,对敌人无效(敌人没有牌堆)
|
|
||||||
# cardDraw: 不施加buff,在抓牌堆洗入同名卡牌
|
|
||||||
# cardHand:不施加buff,在玩家手牌中创建同名卡牌
|
|
||||||
# item: 施加buff到周围物品,永久生效
|
|
||||||
# itemUntilPlayed: 施加buff到周围物品,物品被打出后失效
|
|
||||||
|
|
||||||
id, name, description, timing
|
# item: 施加buff到周围物品,永久生效
|
||||||
string, string, string, 'instant'|'temporary'|'lingering'|'permanent'|'posture'|'card'|'cardDraw'|'cardHand'|'item'|'itemUntilPlayed'
|
# itemTemporary: 施加buff到周围物品,持续到下次buff更新
|
||||||
|
# itemUntilPlay: 施加buff到周围物品,物品被打出后失效
|
||||||
|
# itemUntilDiscard: 施加buff到周围物品,物品被弃掉后失效
|
||||||
|
# itemPermanent: 施加buff到周围物品,持续整场冒险
|
||||||
|
|
||||||
|
id, name, description, lifecycle
|
||||||
|
string, string, string, 'instant'|'temporary'|'lingering'|'permanent'|'posture'|'item'|'itemTemporary'|'itemUntilPlay'|'itemUntilDiscard'|'itemPermanent'
|
||||||
attack, 攻击, 对对手造成伤害, instant
|
attack, 攻击, 对对手造成伤害, instant
|
||||||
defend, 防御, 抵消下次行动前受到的伤害, posture
|
defend, 防御, 抵消下次行动前受到的伤害, posture
|
||||||
spike, 尖刺, 对攻击者造成X点伤害, permanent
|
spike, 尖刺, 对攻击者造成X点伤害, permanent
|
||||||
venom, 蛇毒, 同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害, cardHand
|
venom, 蛇毒, 同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害, instant
|
||||||
curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, lingering
|
curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, lingering
|
||||||
aim, 瞄准, 造成双倍伤害,受伤时失去等量瞄准, posture
|
aim, 瞄准, 造成双倍伤害,受伤时失去等量瞄准, posture
|
||||||
roll, 滚动, 攻击时每消耗10点滚动造成等量伤害, posture
|
roll, 滚动, 攻击时每消耗10点滚动造成等量伤害, posture
|
||||||
rollDamage, 滚动攻击, 消耗滚动层数造成的伤害, instant
|
rollDamage, 滚动攻击, 消耗滚动层数造成的伤害, instant
|
||||||
vultureEye, 秃鹫之眼, 抓到时获得3层暴露(临时debuff,受到的伤害+1/每层), cardDraw
|
vultureEye, 秃鹫之眼, 抓到时获得3层暴露(临时debuff,受到的伤害+1/每层), instant
|
||||||
tailSting, 尾刺, 攻击时,伤害提升X, posture
|
tailSting, 尾刺, 攻击时,伤害提升X, posture
|
||||||
energyDrain, 能量吸取, 受伤时,玩家失去X点能量, lingering
|
energyDrain, 能量吸取, 受伤时,玩家失去X点能量, lingering
|
||||||
molt, 脱皮, 若脱皮达到生命上限则怪物逃跑, posture
|
molt, 脱皮, 若脱皮达到生命上限则怪物逃跑, posture
|
||||||
|
|
@ -35,14 +37,14 @@ crossbow, 十字弩连击, 对同一目标打出其他十字弩, instant
|
||||||
defendNext, 下回合防御, 下回合开始时获得防御, temporary
|
defendNext, 下回合防御, 下回合开始时获得防御, temporary
|
||||||
damageReduce, 减伤, 本回合受到的伤害减少X, temporary
|
damageReduce, 减伤, 本回合受到的伤害减少X, temporary
|
||||||
removeWound, 移除伤口, 从牌堆或弃牌堆移除X张伤口, instant
|
removeWound, 移除伤口, 从牌堆或弃牌堆移除X张伤口, instant
|
||||||
attackBuff, 攻击增益, 周围物品的攻击+X, itemUntilPlayed
|
attackBuff, 攻击增益, 周围物品的攻击+X, itemUntilPlay
|
||||||
defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlayed
|
defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlay
|
||||||
gainEnergy, 获得能量, 获得X点能量, instant
|
gainEnergy, 获得能量, 获得X点能量, instant
|
||||||
energyNext, 下回合获能量, 下回合开始时获得X点能量, temporary
|
energyNext, 下回合获能量, 下回合开始时获得X点能量, temporary
|
||||||
drawNext, 下回合抓牌, 下回合开始时抓X张牌, temporary
|
drawNext, 下回合抓牌, 下回合开始时抓X张牌, temporary
|
||||||
defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlayed
|
defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlay
|
||||||
drawChoice, 选择抓牌, 从牌堆周围物品的牌中选择一张加入手牌, instant
|
drawChoice, 选择抓牌, 从牌堆周围物品的牌中选择一张加入手牌, instant
|
||||||
burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlayed
|
burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlay
|
||||||
attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlayed
|
attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlay
|
||||||
transformRandom, 随机变牌, 选择一张牌随机变为周围物品的牌, instant
|
transformRandom, 随机变牌, 选择一张牌随机变为周围物品的牌, instant
|
||||||
expose, 暴露, 受到的伤害+1/每层, temporary
|
expose, 暴露, 受到的伤害+1/每层, temporary
|
||||||
|
Can't render this file because it has a wrong number of fields in line 12.
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
type EffectTable = readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly lifecycle: "instant" | "temporary" | "lingering" | "permanent" | "posture" | "item" | "itemTemporary" | "itemUntilPlay" | "itemUntilDiscard" | "itemPermanent";
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type Effect = EffectTable[number];
|
||||||
|
|
||||||
|
declare function getData(): EffectTable;
|
||||||
|
export default getData;
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
# minion (10): minor enemies
|
||||||
|
# elite (4): dangerous enemies
|
||||||
|
# event (1): random dangerous event that requires reaction
|
||||||
|
# shop (2): merchant who sells different stuff
|
||||||
|
# camp (2): consumable restock and heal
|
||||||
|
# curio (8): random pickup of treasure or resources
|
||||||
|
# enemies: array of [enemyId; hp; buffs[]]
|
||||||
|
|
||||||
|
id,type,name,description,enemies,dialogue
|
||||||
|
string,'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string,[@enemy; int; [effect: @effect;stacks: int]][],string
|
||||||
|
cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;20;[]];[仙人掌怪;20;[]],
|
||||||
|
snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;14;[]];[蛇;14;[]],
|
||||||
|
mummy_cactus,minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;18;[]];[仙人掌怪;20;[]],
|
||||||
|
gunslinger,minion,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】,[枪手;16;[]],
|
||||||
|
tumbleweed_pair,minion,风卷草,概念:防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。,[风卷草;22;[]];[风卷草;22;[]],
|
||||||
|
vulture_cactus,minion,秃鹫,概念:攻+防。若造成伤害,玩家获得秃鹫之眼(0费状态牌:打出时移除。抓到时获得3层暴露)。,[秃鹫;16;[]];[仙人掌怪;20;[]],
|
||||||
|
scorpion_snake,minion,沙蝎,概念:攻+强化。【尾刺X】:姿态buff,攻击时,伤害提升X。,[沙蝎;14;[]];[蛇;14;[]],
|
||||||
|
sandworm_larva,minion,幼沙虫,概念:防+强化。每回合第一次受伤时,玩家失去1点能量。,[幼沙虫;24;[]],
|
||||||
|
lizard_pair,minion,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。,[蜥蜴;20;[]];[蜥蜴;20;[]],
|
||||||
|
bandit_gunslinger,minion,沙匪,概念:弱化玩家。【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。,[沙匪;16;[]];[枪手;16;[]],
|
||||||
|
storm_spirit,elite,风暴之灵,【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1),[风暴之灵;44;[]],
|
||||||
|
mounted_gunslinger,elite,骑马枪手,【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。,[骑马枪手;50;[]];[枪手;20;[]],
|
||||||
|
sandworm_king,elite,沙虫王,召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。,[沙虫王;55;[]],
|
||||||
|
desert_guard,elite,沙漠守卫,召唤木乃伊;会复活木乃伊2次。,[沙漠守卫;48;[]];[木乃伊;20;[]],
|
||||||
|
desert_merchant,shop,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。,,
|
||||||
|
nomad_caravan,shop,游牧商队,商队:出售稀有物品、移除牌组中一张牌。,,
|
||||||
|
oasis_campfire,camp,绿洲篝火,篝火:可以恢复生命、补充药水使用次数、获得下次战斗Buff。,,
|
||||||
|
cave_shelter,camp,岩洞庇护所,篝火:可以恢复生命、升级一张牌。,,
|
||||||
|
desert_relic_in_sand,curio,沙中遗物,随机获得一件遗物或受到3点伤害。,,desert_relic_in_sand
|
||||||
|
desert_dry_well,curio,枯井,投入1能量:可能获得药水或什么也没有。,,desert_dry_well
|
||||||
|
desert_ancient_stele,curio,古代石碑,阅读碑文:获得随机Buff直到下次战斗结束。,,desert_ancient_stele
|
||||||
|
desert_storm_wreckage,curio,沙暴残骸,搜索残骸:随机获得一张物品牌或受到2点伤害。,,desert_storm_wreckage
|
||||||
|
desert_mirage_chest,curio,蜃景宝箱,打开宝箱:50%获得宝藏,50%为蜃景什么也没有。,,desert_mirage_chest
|
||||||
|
desert_buried_pot,curio,埋藏陶罐,挖掘:获得随机资源(金币、药水或遗物碎片)。,,desert_buried_pot
|
||||||
|
desert_weathered_statue,curio,风化雕像,献祭1生命:获得一件随机遗物。,,desert_weathered_statue
|
||||||
|
desert_oasis_fragment,curio,绿洲碎片,小型绿洲:恢复3生命并获得1张随机消耗品。,,desert_oasis_fragment
|
||||||
|
desert_mirage_event,event,海市蜃楼,随机遭遇:可能获得宝藏或遭遇陷阱,使用d6双阶段结构结算。,,desert_mirage_event
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Enemy } from './enemy.csv';
|
||||||
|
import type { Effect } from './effect.csv';
|
||||||
|
|
||||||
|
type EncounterTable = readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio";
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly enemies: readonly [Enemy, number, readonly [readonly effect: Effect, readonly stacks: number]];
|
||||||
|
readonly dialogue: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type Encounter = EncounterTable[number];
|
||||||
|
|
||||||
|
declare function getData(): EncounterTable;
|
||||||
|
export default getData;
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
id,name,description
|
||||||
|
string,string,string
|
||||||
|
仙人掌怪,仙人掌怪,防+强化。【尖刺X】:对攻击者造成X点伤害。
|
||||||
|
蛇,蛇,攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。
|
||||||
|
木乃伊,木乃伊,攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。
|
||||||
|
枪手,枪手,单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】。
|
||||||
|
风卷草,风卷草,防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。
|
||||||
|
秃鹫,秃鹫,攻+防。若造成伤害,玩家获得秃鹫之眼(0费状态牌:打出时移除。抓到时获得3层暴露)。
|
||||||
|
沙蝎,沙蝎,攻+强化。【尾刺X】:姿态buff,攻击时,伤害提升X。
|
||||||
|
幼沙虫,幼沙虫,防+强化。每回合第一次受伤时,玩家失去1点能量。
|
||||||
|
蜥蜴,蜥蜴,攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。
|
||||||
|
沙匪,沙匪,弱化玩家。【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。
|
||||||
|
风暴之灵,风暴之灵,【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1)
|
||||||
|
骑马枪手,骑马枪手,【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。
|
||||||
|
沙虫王,沙虫王,召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。
|
||||||
|
沙漠守卫,沙漠守卫,召唤木乃伊;会复活木乃伊2次。
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
type EnemyTable = readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type Enemy = EnemyTable[number];
|
||||||
|
|
||||||
|
declare function getData(): EnemyTable;
|
||||||
|
export default getData;
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import getCards from './card.csv';
|
||||||
|
import getEffects from './effect.csv';
|
||||||
|
import getEncounters from './encounter.csv';
|
||||||
|
import getEnemies from './enemy.csv';
|
||||||
|
import getIntents from './intent.csv';
|
||||||
|
import getItems from './item.csv';
|
||||||
|
export { default as dialogues } from './dialogues/dialogues.yarnproject';
|
||||||
|
export { addTriggers } from './triggers';
|
||||||
|
|
||||||
|
export const cards = getCards();
|
||||||
|
export const effects = getEffects();
|
||||||
|
export const encounters = getEncounters();
|
||||||
|
export const enemies = getEnemies();
|
||||||
|
export const intents = getIntents();
|
||||||
|
export const items = getItems();
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
# effects: effects executed when this intent is active
|
# effects: effects executed when this intent is active
|
||||||
|
|
||||||
enemy,intentId,initialIntent,nextIntents,brokenIntent,initBuffs,effects
|
enemy,intentId,initialIntent,nextIntents,brokenIntent,initBuffs,effects
|
||||||
string,string,boolean,string[],string[],[@effectDesert;stacks: int][],['self'|'player'|'team';@effectDesert;number][]
|
@enemy,string,boolean,string[],string[],[@effect;stacks: int][],['self'|'player'|'team';@effect;number][]
|
||||||
仙人掌怪,boost,true,boost;defend;defend,,[spike;1],[self;spike;1];[self;defend;4]
|
仙人掌怪,boost,true,boost;defend;defend,,[spike;1],[self;spike;1];[self;defend;4]
|
||||||
仙人掌怪,defend,false,attack,,[spike;1],[self;defend;8]
|
仙人掌怪,defend,false,attack,,[spike;1],[self;defend;8]
|
||||||
仙人掌怪,attack,false,boost,,[spike;1],[player;attack;5]
|
仙人掌怪,attack,false,boost,,[spike;1],[player;attack;5]
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Enemy } from './enemy.csv';
|
||||||
|
import type { Effect } from './effect.csv';
|
||||||
|
|
||||||
|
type IntentTable = readonly {
|
||||||
|
readonly enemy: Enemy;
|
||||||
|
readonly intentId: string;
|
||||||
|
readonly initialIntent: boolean;
|
||||||
|
readonly nextIntents: readonly string[];
|
||||||
|
readonly brokenIntent: readonly string[];
|
||||||
|
readonly initBuffs: readonly [Effect, readonly stacks: number];
|
||||||
|
readonly effects: readonly ["self" | "player" | "team", Effect, number];
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type Intent = IntentTable[number];
|
||||||
|
|
||||||
|
declare function getData(): IntentTable;
|
||||||
|
export default getData;
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
id,type,name,shape,card,price,description
|
||||||
|
string,string,string,string,@card,int,string
|
||||||
|
sword,weapon,剑,oee,sword,50,【攻击2】【攻击2】
|
||||||
|
greataxe,weapon,长斧,oees,greataxe,80,对全体【攻击5】
|
||||||
|
spear,weapon,长枪,oeee,spear,75,【攻击2】【攻击2】【攻击2】
|
||||||
|
dagger,weapon,短刀,oe,dagger,40,【攻击3】【攻击3】
|
||||||
|
dart,weapon,飞镖,o,dart,30,【攻击1】抓一张牌
|
||||||
|
crossbow,weapon,十字弩,onrersrw,crossbow,120,【攻击6】对同一目标打出其他十字弩
|
||||||
|
shield,armor,盾,oesw,shield,50,【防御3】
|
||||||
|
hat,armor,斗笠,oerwrn,hat,90,【防御8】
|
||||||
|
cape,armor,披风,oers,cape,45,【防御2】下回合【防御2】
|
||||||
|
bracer,armor,护腕,o,bracer,25,【防御1】抓1张牌
|
||||||
|
greatshield,armor,大盾,oesswn,greatshield,70,【防御5】
|
||||||
|
chainmail,armor,锁子甲,oesw,chainmail,60,本回合受到伤害-3
|
||||||
|
bandage,consumable,绷带,o,bandage,20,从牌堆或弃牌堆随机移除1张伤口
|
||||||
|
poisonPotion,consumable,淬毒药剂,o,poisonPotion,30,周围物品的【攻击】+2
|
||||||
|
fortifyPotion,consumable,强固药剂,o,fortifyPotion,30,周围物品的【防御】+2
|
||||||
|
vitalityPotion,consumable,活力药剂,o,vitalityPotion,25,获得1点能量
|
||||||
|
focusPotion,consumable,集中药剂,o,focusPotion,25,抓2张牌
|
||||||
|
healingPotion,consumable,治疗药剂,o,healingPotion,35,从牌堆或弃牌堆移除3张伤口
|
||||||
|
waterBag,tool,水袋,os,waterBag,35,下回合开始时获得1能量抓2张牌
|
||||||
|
rope,tool,绳索,ose,rope,30,周围物品的牌【防御】+2直到打出
|
||||||
|
belt,tool,腰带,owre,belt,40,从牌堆周围物品的牌当中选择一张加入手牌
|
||||||
|
torch,tool,火把,on,torch,25,下次打出周围物品的牌时将其消耗并获得1能量
|
||||||
|
whetstone,tool,磨刀石,o,whetstone,30,周围物品的牌【攻击】+3直到打出
|
||||||
|
blacksmithHammer,tool,铁匠锤,oerwrs,blacksmithHammer,45,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Card } from './card.csv';
|
||||||
|
|
||||||
|
type ItemTable = readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly shape: string;
|
||||||
|
readonly card: Card;
|
||||||
|
readonly price: number;
|
||||||
|
readonly description: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type Item = ItemTable[number];
|
||||||
|
|
||||||
|
declare function getData(): ItemTable;
|
||||||
|
export default getData;
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import {Triggers} from "@/samples/slay-the-spire-like/system/combat/triggers";
|
||||||
|
import {getCombatEntity} from "@/samples/slay-the-spire-like/system/combat/effects";
|
||||||
|
|
||||||
|
export function addEffectTriggers(triggers:Triggers){
|
||||||
|
// instant effects
|
||||||
|
triggers.onEffectApplied.use(async (ctx, next) => {
|
||||||
|
if(ctx.effect.id === "attack") {
|
||||||
|
await triggers.onDamage.execute(ctx.game, {
|
||||||
|
entityKey: ctx.entityKey,
|
||||||
|
amount: ctx.stacks
|
||||||
|
});
|
||||||
|
}else if(ctx.effect.id === "draw") {
|
||||||
|
await triggers.onDraw.execute(ctx.game, {
|
||||||
|
count: ctx.stacks
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// blocks
|
||||||
|
triggers.onDamage.use(async (ctx, next) => {
|
||||||
|
const entity = getCombatEntity(ctx.game.value, ctx.entityKey);
|
||||||
|
if(!entity) return;
|
||||||
|
|
||||||
|
const preventable = (ctx.amount - (ctx.prevented ?? 0));
|
||||||
|
const blocks = entity.effects.block?.stacks ?? 0;
|
||||||
|
const blocked = Math.min(blocks, preventable);
|
||||||
|
if(blocked){
|
||||||
|
ctx.prevented = (ctx.prevented ?? 0) + blocked;
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import {addEffectTriggers} from './effect';
|
||||||
|
import {Triggers} from "@/samples/slay-the-spire-like/system/combat/triggers";
|
||||||
|
|
||||||
|
export function addTriggers(triggers: Triggers){
|
||||||
|
addEffectTriggers(triggers);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
type EffectDesertTable = readonly {
|
|
||||||
readonly id: string;
|
|
||||||
readonly name: string;
|
|
||||||
readonly description: string;
|
|
||||||
readonly timing: "instant" | "temporary" | "lingering" | "permanent" | "posture" | "card" | "cardDraw" | "cardHand" | "item" | "itemUntilPlayed";
|
|
||||||
}[];
|
|
||||||
|
|
||||||
export type EffectDesert = EffectDesertTable[number];
|
|
||||||
|
|
||||||
declare function getData(): EffectDesertTable;
|
|
||||||
export default getData;
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
# minion (10): minor enemies
|
|
||||||
# elite (4): dangerous enemies
|
|
||||||
# event (1): random dangerous event that requires reaction
|
|
||||||
# shop (2): merchant who sells different stuff
|
|
||||||
# camp (2): consumable restock and heal
|
|
||||||
# curio (8): random pickup of treasure or resources
|
|
||||||
# enemies: array of [enemyId; hp; bonusHp] - bonusHp for scaling
|
|
||||||
type,name,description,enemies,dialogue
|
|
||||||
'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string,[string; int; int][],string
|
|
||||||
minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;20;0];[仙人掌怪;20;0],
|
|
||||||
minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;14;0];[蛇;14;0],
|
|
||||||
minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;18;0];[仙人掌怪;20;0],
|
|
||||||
minion,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】,[枪手;16;0],
|
|
||||||
minion,风卷草,概念:防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。,[风卷草;22;0];[风卷草;22;0],
|
|
||||||
minion,秃鹫,概念:攻+防。若造成伤害,玩家获得秃鹫之眼(0费状态牌:打出时移除。抓到时获得3层暴露)。,[秃鹫;16;0];[仙人掌怪;20;0],
|
|
||||||
minion,沙蝎,概念:攻+强化。【尾刺X】:姿态buff,攻击时,伤害提升X。,[沙蝎;14;0];[蛇;14;0],
|
|
||||||
minion,幼沙虫,概念:防+强化。每回合第一次受伤时,玩家失去1点能量。,[幼沙虫;24;0],
|
|
||||||
minion,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。,[蜥蜴;20;0];[蜥蜴;20;0],
|
|
||||||
minion,沙匪,概念:弱化玩家。【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。,[沙匪;16;0];[枪手;16;0],
|
|
||||||
elite,风暴之灵,【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1),[风暴之灵;44;0],
|
|
||||||
elite,骑马枪手,【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。,[骑马枪手;50;0];[枪手;20;4],
|
|
||||||
elite,沙虫王,召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。,[沙虫王;55;0],
|
|
||||||
elite,沙漠守卫,召唤木乃伊;会复活木乃伊2次。,[沙漠守卫;48;0];[木乃伊;20;0],
|
|
||||||
shop,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。,,
|
|
||||||
shop,游牧商队,商队:出售稀有物品、移除牌组中一张牌。,,
|
|
||||||
camp,绿洲篝火,篝火:可以恢复生命、补充药水使用次数、获得下次战斗Buff。,,
|
|
||||||
camp,岩洞庇护所,篝火:可以恢复生命、升级一张牌。,,
|
|
||||||
curio,沙中遗物,随机获得一件遗物或受到3点伤害。,,desert_relic_in_sand
|
|
||||||
curio,枯井,投入1能量:可能获得药水或什么也没有。,,desert_dry_well
|
|
||||||
curio,古代石碑,阅读碑文:获得随机Buff直到下次战斗结束。,,desert_ancient_stele
|
|
||||||
curio,沙暴残骸,搜索残骸:随机获得一张物品牌或受到2点伤害。,,desert_storm_wreckage
|
|
||||||
curio,蜃景宝箱,打开宝箱:50%获得宝藏,50%为蜃景什么也没有。,,desert_mirage_chest
|
|
||||||
curio,埋藏陶罐,挖掘:获得随机资源(金币、药水或遗物碎片)。,,desert_buried_pot
|
|
||||||
curio,风化雕像,献祭1生命:获得一件随机遗物。,,desert_weathered_statue
|
|
||||||
curio,绿洲碎片,小型绿洲:恢复3生命并获得1张随机消耗品。,,desert_oasis_fragment
|
|
||||||
event,海市蜃楼,随机遭遇:可能获得宝藏或遭遇陷阱,使用d6双阶段结构结算。,,desert_mirage_event
|
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
type EncounterDesertTable = readonly {
|
|
||||||
readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio";
|
|
||||||
readonly name: string;
|
|
||||||
readonly description: string;
|
|
||||||
readonly enemies: readonly [string, number, number];
|
|
||||||
readonly dialogue: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
export type EncounterDesert = EncounterDesertTable[number];
|
|
||||||
|
|
||||||
declare function getData(): EncounterDesertTable;
|
|
||||||
export default getData;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import type { EffectDesert } from './effectDesert.csv';
|
|
||||||
|
|
||||||
type EnemyDesertTable = readonly {
|
|
||||||
readonly enemy: string;
|
|
||||||
readonly intentId: string;
|
|
||||||
readonly initialIntent: boolean;
|
|
||||||
readonly nextIntents: readonly string[];
|
|
||||||
readonly brokenIntent: readonly string[];
|
|
||||||
readonly initBuffs: readonly [EffectDesert, readonly stacks: number];
|
|
||||||
readonly effects: readonly ["self" | "player" | "team", EffectDesert, number];
|
|
||||||
}[];
|
|
||||||
|
|
||||||
export type EnemyDesert = EnemyDesertTable[number];
|
|
||||||
|
|
||||||
declare function getData(): EnemyDesertTable;
|
|
||||||
export default getData;
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
type,name,shape,card,price
|
|
||||||
string,string,string,@cardDesert,int
|
|
||||||
weapon,剑,oee,sword,50
|
|
||||||
weapon,长斧,oees,greataxe,80
|
|
||||||
weapon,长枪,oeee,spear,75
|
|
||||||
weapon,短刀,oe,dagger,40
|
|
||||||
weapon,飞镖,o,dart,30
|
|
||||||
weapon,十字弩,onrersrw,crossbow,120
|
|
||||||
armor,盾,oesw,shield,50
|
|
||||||
armor,斗笠,oerwrn,hat,90
|
|
||||||
armor,披风,oers,cape,45
|
|
||||||
armor,护腕,o,bracer,25
|
|
||||||
armor,大盾,oesswn,greatshield,70
|
|
||||||
armor,锁子甲,oesw,chainmail,60
|
|
||||||
consumable,绷带,o,bandage,20
|
|
||||||
consumable,淬毒药剂,o,poisonPotion,30
|
|
||||||
consumable,强固药剂,o,fortifyPotion,30
|
|
||||||
consumable,活力药剂,o,vitalityPotion,25
|
|
||||||
consumable,集中药剂,o,focusPotion,25
|
|
||||||
consumable,治疗药剂,o,healingPotion,35
|
|
||||||
tool,水袋,os,waterBag,35
|
|
||||||
tool,绳索,ose,rope,30
|
|
||||||
tool,腰带,owre,belt,40
|
|
||||||
tool,火把,on,torch,25
|
|
||||||
tool,磨刀石,o,whetstone,30
|
|
||||||
tool,铁匠锤,oerwrs,blacksmithHammer,45
|
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
import type { CardDesert } from './cardDesert.csv';
|
|
||||||
|
|
||||||
type HeroItemFighter1Table = readonly {
|
|
||||||
readonly type: string;
|
|
||||||
readonly name: string;
|
|
||||||
readonly shape: string;
|
|
||||||
readonly card: CardDesert;
|
|
||||||
readonly price: number;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
export type HeroItemFighter1 = HeroItemFighter1Table[number];
|
|
||||||
|
|
||||||
declare function getData(): HeroItemFighter1Table;
|
|
||||||
export default getData;
|
|
||||||
|
|
@ -1,16 +1,5 @@
|
||||||
import heroItemFighter1Csv from './heroItemFighter1.csv';
|
import * as desert from './desert';
|
||||||
import encounterDesertCsv from './encounterDesert.csv';
|
|
||||||
import enemyDesertCsv from './enemyDesert.csv';
|
|
||||||
import effectDesertCsv from './effectDesert.csv';
|
|
||||||
import cardDesertCsv from './cardDesert.csv';
|
|
||||||
|
|
||||||
export const heroItemFighter1Data = heroItemFighter1Csv();
|
export default {
|
||||||
export const encounterDesertData = encounterDesertCsv();
|
desert
|
||||||
export const enemyDesertData = enemyDesertCsv();
|
}
|
||||||
export const effectDesertData = effectDesertCsv();
|
|
||||||
export const cardDesertData = cardDesertCsv();
|
|
||||||
|
|
||||||
export { default as encounterDesertCsv, type EncounterDesert } from './encounterDesert.csv';
|
|
||||||
export { default as enemyDesertCsv, type EnemyDesert } from './enemyDesert.csv';
|
|
||||||
export { default as effectDesertCsv, type EffectDesert } from './effectDesert.csv';
|
|
||||||
export { default as cardDesertCsv, type CardDesert } from './cardDesert.csv';
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
# 战斗规则
|
|
||||||
|
|
||||||
## 战斗状态
|
|
||||||
|
|
||||||
角色表:
|
|
||||||
- 敌方角色表
|
|
||||||
- 我方角色表
|
|
||||||
|
|
||||||
角色状态:
|
|
||||||
- hp/最大hp
|
|
||||||
- buff表(名称->层数,同名buff合并叠加)
|
|
||||||
|
|
||||||
战利品表:
|
|
||||||
- 类型
|
|
||||||
- 数量
|
|
||||||
|
|
||||||
触发规则表:
|
|
||||||
- 类型
|
|
||||||
- 层数
|
|
||||||
|
|
||||||
## 战斗开始
|
|
||||||
|
|
||||||
战斗开始时,首先进行敌方角色的战斗开始结算,然后进行玩家的战斗开始结算。
|
|
||||||
|
|
||||||
战斗开始会让玩家抓5张起始手牌,让敌方角色初始化意图。
|
|
||||||
|
|
||||||
## 回合
|
|
||||||
|
|
||||||
进行一个玩家回合,然后进行一个敌方回合,以此重复,直到不存在玩家或敌方角色存活。
|
|
||||||
|
|
||||||
玩家回合包含以下阶段:
|
|
||||||
|
|
||||||
- buff更新:temporary buff清空,lingering buff层数-1。
|
|
||||||
- 回合开始:触发回合开始结算的效果。
|
|
||||||
- 玩家行动:玩家可以花费能量打出手牌。点结束来结束行动。
|
|
||||||
- 回合结束:触发回合结束结算的效果。
|
|
||||||
- 重置手牌:弃掉剩余的手牌,重新抓5张手牌。
|
|
||||||
- 重置能量:重置到3点能量。
|
|
||||||
|
|
||||||
敌人回合包含以下阶段:
|
|
||||||
|
|
||||||
- buff更新:每个敌人的temporary buff清空,lingering buff层数-1。
|
|
||||||
- 回合开始:触发回合开始结算的效果。
|
|
||||||
- 敌人行动:每个敌人的意图依次生效,然后更新下一个意图。
|
|
||||||
- 回合结束:触发回合结束结算的效果。
|
|
||||||
|
|
||||||
## 效果
|
|
||||||
|
|
||||||
效果结算时,具有以下上下文:
|
|
||||||
- name: 效果名称
|
|
||||||
- stacks: 层数
|
|
||||||
- target: 目标角色,如果有
|
|
||||||
- source:来源角色,如果有
|
|
||||||
- card:来源卡牌,如果有
|
|
||||||
|
|
||||||
效果有以下类型:
|
|
||||||
|
|
||||||
- Instant: 立即结算。
|
|
||||||
|
|
||||||
- Buff: 施加为buff,持续整场战斗。
|
|
||||||
- BuffTemporary: 施加为buff,下次buff更新时清空。
|
|
||||||
- BuffLingering: 施加为buff,下次buff更新时层数-1。
|
|
||||||
- BuffPosture: 施加为buff,受到伤害时扣除等量层数。
|
|
||||||
|
|
||||||
- Item: 施加为物品buff,对来源卡牌对应的物品周围的物品生效。
|
|
||||||
- ItemUntilPlayed:施加为物品buff,对来源卡牌对应的物品生效,生效一次后失效。
|
|
||||||
- ItemTemporary: 施加为物品buff,对来源卡牌对应的物品生效,下次buff更新时清空。
|
|
||||||
- ItemPermanent: 施加为物品buff,对来源卡牌对应的物品生效,战斗结束后依然有效。
|
|
||||||
|
|
||||||
- Card: 施加为状态卡牌,洗入玩家弃牌堆。对敌人无效。
|
|
||||||
- CardDraw:洗入抓牌堆。
|
|
||||||
- CardHand:加入手牌。
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
import type { CellKey, GridInventory, InventoryItem } from '../grid-inventory/types';
|
|
||||||
import type { GameItemMeta } from '../progress/types';
|
|
||||||
import { createRegion, createRegionAxis } from '@/core/region';
|
|
||||||
import type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types';
|
|
||||||
import { cardDesertData } from '../data';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a unique card ID for a cell within an item.
|
|
||||||
*/
|
|
||||||
function generateCardId(itemId: string, cellIndex: number): string {
|
|
||||||
return `card-${itemId}-${cellIndex}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collects all cell keys from an item's shape in a deterministic order.
|
|
||||||
* Iterates the shape grid row by row, left to right, top to bottom.
|
|
||||||
*/
|
|
||||||
function getItemCells(item: InventoryItem<GameItemMeta>): CellKey[] {
|
|
||||||
const cells: CellKey[] = [];
|
|
||||||
const { shape, transform } = item;
|
|
||||||
const { grid } = shape;
|
|
||||||
const { offset, rotation, flipX, flipY } = transform;
|
|
||||||
|
|
||||||
// Track local dimensions (may swap on rotation)
|
|
||||||
let localWidth = grid[0]?.length || 1;
|
|
||||||
let localHeight = grid.length;
|
|
||||||
|
|
||||||
for (let gy = 0; gy < grid.length; gy++) {
|
|
||||||
for (let gx = 0; gx < grid[gy].length; gx++) {
|
|
||||||
if (!grid[gy][gx]) continue;
|
|
||||||
|
|
||||||
// Start from grid coordinates
|
|
||||||
let x = gx;
|
|
||||||
let y = gy;
|
|
||||||
|
|
||||||
// Apply rotation (90 degree increments, clockwise)
|
|
||||||
const rotTimes = ((rotation % 4) + 4) % 4;
|
|
||||||
for (let r = 0; r < rotTimes; r++) {
|
|
||||||
const newX = localHeight - 1 - y;
|
|
||||||
const newY = x;
|
|
||||||
x = newX;
|
|
||||||
y = newY;
|
|
||||||
// Swap dimensions for next iteration
|
|
||||||
const tmp = localWidth;
|
|
||||||
localWidth = localHeight;
|
|
||||||
localHeight = tmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset local dimensions for fresh computation per cell
|
|
||||||
localWidth = grid[0]?.length || 1;
|
|
||||||
localHeight = grid.length;
|
|
||||||
|
|
||||||
// Apply flips
|
|
||||||
if (flipX) {
|
|
||||||
x = -x;
|
|
||||||
}
|
|
||||||
if (flipY) {
|
|
||||||
y = -y;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply offset
|
|
||||||
const finalX = x + offset.x;
|
|
||||||
const finalY = y + offset.y;
|
|
||||||
|
|
||||||
cells.push(`${finalX},${finalY}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cells;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a single card from an inventory item.
|
|
||||||
*/
|
|
||||||
function createItemCard(
|
|
||||||
itemId: string,
|
|
||||||
itemData: GameItemMeta['itemData'],
|
|
||||||
cellKey: CellKey,
|
|
||||||
cellIndex: number
|
|
||||||
): GameCard {
|
|
||||||
const cardId = generateCardId(itemId, cellIndex);
|
|
||||||
const cardData = cardDesertData.find(c => c.id === itemData.card.id);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: cardId,
|
|
||||||
regionId: '',
|
|
||||||
position: [],
|
|
||||||
sourceItemId: itemId,
|
|
||||||
itemData: cardData ?? null,
|
|
||||||
cellKey,
|
|
||||||
displayName: cardData?.name ?? itemData.name,
|
|
||||||
description: cardData?.desc ?? '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a status card that does not correspond to any inventory item.
|
|
||||||
* Status cards represent temporary effects like wounds, stuns, etc.
|
|
||||||
*/
|
|
||||||
function createStatusCard(
|
|
||||||
id: string,
|
|
||||||
displayName: string,
|
|
||||||
description: string
|
|
||||||
): GameCard {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
regionId: '',
|
|
||||||
position: [],
|
|
||||||
sourceItemId: null,
|
|
||||||
itemData: null,
|
|
||||||
cellKey: null,
|
|
||||||
displayName,
|
|
||||||
description,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a complete player deck from the current inventory state.
|
|
||||||
*/
|
|
||||||
function generateDeckFromInventory(inventory: GridInventory<GameItemMeta>): PlayerDeck {
|
|
||||||
const cards: Record<string, GameCard> = {};
|
|
||||||
const drawPile: string[] = [];
|
|
||||||
|
|
||||||
for (const item of inventory.items.values()) {
|
|
||||||
const itemData = item.meta?.itemData;
|
|
||||||
if (!itemData) continue;
|
|
||||||
|
|
||||||
// Generate one card per occupied cell in the item's shape
|
|
||||||
const cellCount = item.shape.count;
|
|
||||||
const cells = getItemCells(item);
|
|
||||||
|
|
||||||
for (let i = 0; i < cellCount; i++) {
|
|
||||||
const cellKey = cells[i] ?? `${i},0`;
|
|
||||||
const card = createItemCard(item.id, itemData, cellKey, i);
|
|
||||||
cards[card.id] = card;
|
|
||||||
drawPile.push(card.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cards,
|
|
||||||
drawPile,
|
|
||||||
hand: [],
|
|
||||||
discardPile: [],
|
|
||||||
exhaustPile: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates region definitions for deck management.
|
|
||||||
*/
|
|
||||||
function createDeckRegions(): DeckRegions {
|
|
||||||
return {
|
|
||||||
drawPile: createRegion('drawPile', [
|
|
||||||
createRegionAxis('index', 0, 0),
|
|
||||||
]),
|
|
||||||
hand: createRegion('hand', [
|
|
||||||
createRegionAxis('index', 0, 0),
|
|
||||||
]),
|
|
||||||
discardPile: createRegion('discardPile', [
|
|
||||||
createRegionAxis('index', 0, 0),
|
|
||||||
]),
|
|
||||||
exhaustPile: createRegion('exhaustPile', [
|
|
||||||
createRegionAxis('index', 0, 0),
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an empty player deck structure.
|
|
||||||
*/
|
|
||||||
function createPlayerDeck(): PlayerDeck {
|
|
||||||
return {
|
|
||||||
cards: {},
|
|
||||||
drawPile: [],
|
|
||||||
hand: [],
|
|
||||||
discardPile: [],
|
|
||||||
exhaustPile: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
generateDeckFromInventory,
|
|
||||||
createStatusCard,
|
|
||||||
createDeckRegions,
|
|
||||||
createPlayerDeck,
|
|
||||||
generateCardId,
|
|
||||||
};
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import type { Part } from '@/core/part';
|
|
||||||
import type { Region } from '@/core/region';
|
|
||||||
import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv';
|
|
||||||
import type { CardDesert } from '../data/cardDesert.csv';
|
|
||||||
import type { CellKey } from '../grid-inventory/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Metadata for a game card.
|
|
||||||
* Bridges inventory item data with the card system.
|
|
||||||
*/
|
|
||||||
export interface GameCardMeta {
|
|
||||||
/**
|
|
||||||
* Source item instance ID that this card was generated from.
|
|
||||||
* `null` for status cards (e.g. wound, stun) that don't correspond to an inventory item.
|
|
||||||
*/
|
|
||||||
sourceItemId: string | null;
|
|
||||||
/**
|
|
||||||
* Original card data from cardDesert.csv. `null` for status cards not in the CSV.
|
|
||||||
*/
|
|
||||||
itemData: CardDesert | null;
|
|
||||||
/**
|
|
||||||
* The cell key ("x,y") this card represents within the source item's shape.
|
|
||||||
* `null` for status cards.
|
|
||||||
*/
|
|
||||||
cellKey: CellKey | null;
|
|
||||||
/**
|
|
||||||
* Display name of the card.
|
|
||||||
* For item cards: derived from itemData.name.
|
|
||||||
* For status cards: custom name (e.g. "伤口", "眩晕").
|
|
||||||
*/
|
|
||||||
displayName: string;
|
|
||||||
/**
|
|
||||||
* Card description / ability text.
|
|
||||||
* For item cards: derived from itemData.desc.
|
|
||||||
* For status cards: custom description.
|
|
||||||
*/
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A card instance in the game.
|
|
||||||
* Cards are generated from inventory items or created as status effects.
|
|
||||||
*/
|
|
||||||
export type GameCard = Part<GameCardMeta>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Player deck structure containing card pools.
|
|
||||||
*/
|
|
||||||
export interface PlayerDeck {
|
|
||||||
/** All cards indexed by ID */
|
|
||||||
cards: Record<string, GameCard>;
|
|
||||||
/** Card IDs in the draw pile */
|
|
||||||
drawPile: string[];
|
|
||||||
/** Card IDs in the player's hand */
|
|
||||||
hand: string[];
|
|
||||||
/** Card IDs in the discard pile */
|
|
||||||
discardPile: string[];
|
|
||||||
/** Card IDs in the exhaust pile (removed from combat) */
|
|
||||||
exhaustPile: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Region structure for deck management.
|
|
||||||
*/
|
|
||||||
export interface DeckRegions {
|
|
||||||
/** Draw pile region */
|
|
||||||
drawPile: Region;
|
|
||||||
/** Hand region */
|
|
||||||
hand: Region;
|
|
||||||
/** Discard pile region */
|
|
||||||
discardPile: Region;
|
|
||||||
/** Exhaust pile region */
|
|
||||||
exhaustPile: Region;
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export {default as encounters} from './encounters/encounters.yarnproject';
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# 文件结构
|
||||||
|
|
||||||
|
## 系统实现
|
||||||
|
|
||||||
|
- `system/`:系统实现。
|
||||||
|
- `combat/`:管理一次战斗内的战斗状态、流程、触发。
|
||||||
|
- `progress/`:管理一次冒险的进度。
|
||||||
|
- `map/`:管理地图生成。
|
||||||
|
- `grid-inventory/`:管理背包状态。
|
||||||
|
- `deck/`:管理卡组状态。
|
||||||
|
|
||||||
|
## 数据
|
||||||
|
|
||||||
|
- `data/`:所有的数据文件
|
||||||
|
- `desert/`:游戏的测试模组,沙漠区域的内容。
|
||||||
|
- `card.csv`:所有的卡牌定义。
|
||||||
|
- `effect.csv`:所有的效应定义。
|
||||||
|
- `encounter.csv`:所有的遭遇定义。
|
||||||
|
- `enemy.csv`:所有的敌人和意图定义。
|
||||||
|
- `item.csv`:所有的玩家物品定义。
|
||||||
|
- `dialogues/`:yarn对话项目目录
|
||||||
|
- `dialogues.yarnproject`:yarn对话项目入口
|
||||||
|
- `**.yarn`:具体的yarn对话文件,代码不关心
|
||||||
|
- `index.ts`:统一导出所有的数据。
|
||||||
|
- `trigger.ts`:额外的触发器脚本,用于实现测试模组中涉及的效果。
|
||||||
|
|
||||||
|
## 开发流程
|
||||||
|
|
||||||
|
### SRD / 系统设计
|
||||||
|
|
||||||
|
首先编写srd文档。srd文档描述游戏系统和规则,并提供少量内联的基础游戏数据。
|
||||||
|
|
||||||
|
SRD是系统实现的基础,系统实现只能基于SRD,不能依赖具体的游戏内容。
|
||||||
|
|
||||||
|
### Setting / 环境设定
|
||||||
|
|
||||||
|
使用SRD提供的特性来设计环境内容:
|
||||||
|
- 效应:效应在技术上不依赖其他类型内容,可以首先确定。但在创意上可能不会直接设计效应,而是从若干创意中提取共性,并创建一个效应来作为主题。
|
||||||
|
- 卡牌:部分效应会创建卡牌或修改卡牌。在设计时需要注意。
|
||||||
|
- 敌人和意图:结合效应与主题表达设计敌人。通常需要为每个敌人设计独特的效应。
|
||||||
|
- 玩家物品:结合效应与主题表达设计物品。常见物品的效应容易找到替代,不常见物品则有特殊的效应。
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
// Data
|
|
||||||
export { heroItemFighter1Data, encounterDesertData, enemyDesertData, effectDesertData, cardDesertData } from './data';
|
|
||||||
export { default as encounterDesertCsv } from './data/encounterDesert.csv';
|
|
||||||
export type { EncounterDesert, CardDesert } from './data';
|
|
||||||
|
|
||||||
// Deck
|
|
||||||
export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './deck';
|
|
||||||
export {
|
|
||||||
generateDeckFromInventory,
|
|
||||||
createStatusCard,
|
|
||||||
createDeckRegions,
|
|
||||||
createPlayerDeck,
|
|
||||||
generateCardId,
|
|
||||||
} from './deck';
|
|
||||||
|
|
||||||
// Grid Inventory
|
|
||||||
export type { CellCoordinate, CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './grid-inventory';
|
|
||||||
export {
|
|
||||||
createGridInventory,
|
|
||||||
flipItem,
|
|
||||||
getAdjacentItems,
|
|
||||||
getItemAtCell,
|
|
||||||
getOccupiedCellSet,
|
|
||||||
moveItem,
|
|
||||||
placeItem,
|
|
||||||
removeItem,
|
|
||||||
rotateItem,
|
|
||||||
validatePlacement,
|
|
||||||
} from './grid-inventory';
|
|
||||||
|
|
||||||
// Map
|
|
||||||
export { MapNodeType, MapLayerType } from './map';
|
|
||||||
export type { MapNode, MapLayer, PointCrawlMap } from './map';
|
|
||||||
export { generatePointCrawlMap, getNode, getChildren, getParents, hasPath, findAllPaths } from './map';
|
|
||||||
|
|
||||||
// Progress Manager
|
|
||||||
export type {
|
|
||||||
EncounterResult,
|
|
||||||
EncounterState,
|
|
||||||
GameItem,
|
|
||||||
GameItemMeta,
|
|
||||||
PlayerState,
|
|
||||||
RunMutationResult,
|
|
||||||
RunState,
|
|
||||||
} from './progress';
|
|
||||||
export {
|
|
||||||
addGold,
|
|
||||||
addItemFromCsv,
|
|
||||||
canMoveTo,
|
|
||||||
createRunState,
|
|
||||||
damagePlayer,
|
|
||||||
getReachableChildren,
|
|
||||||
getCurrentNode,
|
|
||||||
getUnresolvedChildren,
|
|
||||||
healPlayer,
|
|
||||||
isAtEndNode,
|
|
||||||
isAtStartNode,
|
|
||||||
isEncounterResolved,
|
|
||||||
moveToNode,
|
|
||||||
removeItem as removeItemFromRun,
|
|
||||||
resolveEncounter,
|
|
||||||
setMaxHp,
|
|
||||||
spendGold,
|
|
||||||
} from './progress';
|
|
||||||
|
|
||||||
// Utils - Parse Shape
|
|
||||||
export type { ParsedShape } from './utils/parse-shape';
|
|
||||||
export { parseShapeString } from './utils/parse-shape';
|
|
||||||
|
|
||||||
// Utils - Shape Collision
|
|
||||||
export type { Point2D, Transform2D } from './utils/shape-collision';
|
|
||||||
export {
|
|
||||||
IDENTITY_TRANSFORM,
|
|
||||||
getOccupiedCells,
|
|
||||||
transformPoint,
|
|
||||||
transformShape,
|
|
||||||
checkCollision,
|
|
||||||
checkBoardCollision,
|
|
||||||
checkBounds,
|
|
||||||
validatePlacement as validateShapePlacement,
|
|
||||||
rotateTransform,
|
|
||||||
flipXTransform,
|
|
||||||
flipYTransform,
|
|
||||||
} from './utils/shape-collision';
|
|
||||||
|
|
||||||
// Combat
|
|
||||||
export type {
|
|
||||||
BuffTable,
|
|
||||||
CombatEffectEntry,
|
|
||||||
CombatEntity,
|
|
||||||
CombatGameContext,
|
|
||||||
CombatPhase,
|
|
||||||
CombatResult,
|
|
||||||
CombatState,
|
|
||||||
EffectTarget,
|
|
||||||
EffectTiming,
|
|
||||||
EnemyState,
|
|
||||||
ItemBuff,
|
|
||||||
LootEntry,
|
|
||||||
PlayerCombatState,
|
|
||||||
} from './combat';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
TriggerContext,
|
|
||||||
BuffTriggerBehavior,
|
|
||||||
CombatTriggerRegistry,
|
|
||||||
TriggerEvent,
|
|
||||||
} from './combat';
|
|
||||||
|
|
||||||
export {
|
|
||||||
createCombatState,
|
|
||||||
createEnemyInstance,
|
|
||||||
createPlayerCombatState,
|
|
||||||
drawCardsToHand,
|
|
||||||
addFatigueCards,
|
|
||||||
discardHand,
|
|
||||||
discardCard,
|
|
||||||
exhaustCard,
|
|
||||||
getEnemyCurrentIntent,
|
|
||||||
advanceEnemyIntent,
|
|
||||||
getEffectTiming,
|
|
||||||
getEffectData,
|
|
||||||
INITIAL_HAND_SIZE,
|
|
||||||
DEFAULT_MAX_ENERGY,
|
|
||||||
FATIGUE_CARDS_PER_SHUFFLE,
|
|
||||||
applyDamage,
|
|
||||||
applyDefend,
|
|
||||||
applyBuff,
|
|
||||||
removeBuff,
|
|
||||||
updateBuffs,
|
|
||||||
resolveEffect,
|
|
||||||
resolveCardEffects,
|
|
||||||
getModifiedAttackDamage,
|
|
||||||
getModifiedDefendAmount,
|
|
||||||
canPlayCard,
|
|
||||||
playCard,
|
|
||||||
areAllEnemiesDead,
|
|
||||||
isPlayerDead,
|
|
||||||
createCombatTriggerRegistry,
|
|
||||||
dispatchTrigger,
|
|
||||||
dispatchAttackedTrigger,
|
|
||||||
dispatchDamageTrigger,
|
|
||||||
dispatchOutgoingDamageTrigger,
|
|
||||||
dispatchIncomingDamageTrigger,
|
|
||||||
dispatchShuffleTrigger,
|
|
||||||
runCombat,
|
|
||||||
} from './combat';
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import {CombatEntity, CombatState, EffectTable, PlayerEntity} from "./types";
|
||||||
|
import {CardData, EffectData} from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress/types";
|
||||||
|
import {GridInventory} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
|
||||||
|
|
||||||
|
export function addEffect(effects: EffectTable, effect: EffectData, stacks: number){
|
||||||
|
let current = effects[effect.id];
|
||||||
|
|
||||||
|
if(!current) current = {data: effect, stacks};
|
||||||
|
else current.stacks += stacks;
|
||||||
|
|
||||||
|
if(current.stacks === 0 && effects[effect.id])
|
||||||
|
delete effects[effect.id];
|
||||||
|
else if(current.stacks !== 0 && !effects[effect.id])
|
||||||
|
effects[effect.id] = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addEntityEffect(entity: CombatEntity, effect: EffectData, stacks: number){
|
||||||
|
addEffect(entity.effects, effect, stacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addItemEffect(entity: PlayerEntity, itemKey: string, effect: EffectData, stacks: number){
|
||||||
|
entity.itemEffects[itemKey] = entity.itemEffects[itemKey] || {};
|
||||||
|
addEffect(entity.itemEffects[itemKey], effect, stacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onEntityEffectUpkeep(entity: CombatEntity){
|
||||||
|
for(const effect of Object.values(entity.effects)){
|
||||||
|
const lifecycle = effect.data.lifecycle;
|
||||||
|
if(lifecycle === 'temporary')
|
||||||
|
addEntityEffect(entity, effect.data, -effect.stacks);
|
||||||
|
else if(lifecycle === 'lingering')
|
||||||
|
addEntityEffect(entity, effect.data, effect.stacks >= 0 ? -1 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onEntityPostureDamage(entity: CombatEntity, damage: number){
|
||||||
|
for(const effect of Object.values(entity.effects)){
|
||||||
|
const lifecycle = effect.data.lifecycle;
|
||||||
|
if(lifecycle === 'posture')
|
||||||
|
addEntityEffect(entity, effect.data, -Math.min(damage, effect.stacks));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPlayerItemEffectUpkeep(entity: PlayerEntity){
|
||||||
|
for(const [itemKey, itemEffects] of Object.entries(entity.itemEffects)){
|
||||||
|
for(const effect of Object.values(itemEffects)){
|
||||||
|
const lifecycle = effect.data.lifecycle;
|
||||||
|
if(lifecycle === 'itemTemporary')
|
||||||
|
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onItemPlay(entity: PlayerEntity, itemKey: string){
|
||||||
|
const effects = entity.itemEffects[itemKey];
|
||||||
|
if(!effects)return;
|
||||||
|
for(const effect of Object.values(effects)){
|
||||||
|
if(effect.data.lifecycle === 'itemUntilPlay'){
|
||||||
|
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onItemDiscard(entity: PlayerEntity, itemKey: string){
|
||||||
|
const effects = entity.itemEffects[itemKey];
|
||||||
|
if(!effects)return;
|
||||||
|
for(const effect of Object.values(effects)){
|
||||||
|
if(effect.data.lifecycle === 'itemUntilDiscard'){
|
||||||
|
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* getAliveEnemies(state: CombatState) {
|
||||||
|
for (let enemy of state.enemies) {
|
||||||
|
if (enemy.isAlive) {
|
||||||
|
yield enemy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCombatEntity(state: CombatState, entityKey: string){
|
||||||
|
return entityKey === 'player' ? state.player : state.enemies.find(e => e.id === entityKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canPlayCard(player: PlayerEntity, costType: CardData['costType'], costCount: number, itemId: string, inventory: GridInventory<GameItemMeta>): boolean {
|
||||||
|
if (costType === 'energy') {
|
||||||
|
return player.energy >= costCount;
|
||||||
|
}
|
||||||
|
if (costType === 'uses') {
|
||||||
|
const item = inventory.items.get(itemId);
|
||||||
|
if (!item || !item.meta) return false;
|
||||||
|
const depletion = item.meta.depletion ?? 0;
|
||||||
|
return depletion < costCount;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function payCardCost(player: PlayerEntity, costType: CardData['costType'], costCount: number, itemId: string, inventory: GridInventory<GameItemMeta>): void {
|
||||||
|
if (costType === 'energy') {
|
||||||
|
player.energy -= costCount;
|
||||||
|
} else if (costType === 'uses') {
|
||||||
|
const item = inventory.items.get(itemId);
|
||||||
|
if (item && item.meta) {
|
||||||
|
item.meta.depletion = (item.meta.depletion ?? 0) + costCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { createPromptDef } from "@/core/game";
|
||||||
|
import {CombatGameContext} from "./types";
|
||||||
|
import {canPlayCard} from "@/samples/slay-the-spire-like/system/combat/effects";
|
||||||
|
|
||||||
|
export const prompts = {
|
||||||
|
mainAction: createPromptDef<[string, string?]>(
|
||||||
|
"main-action <cardId:string> [targetId:string]",
|
||||||
|
"选择卡牌并指定目标"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function promptMainAction(game: CombatGameContext){
|
||||||
|
return await game.prompt(prompts.mainAction, (cardId, targetId) => {
|
||||||
|
if(cardId === 'end-turn') return {
|
||||||
|
action: 'end-turn' as 'end-turn'
|
||||||
|
};
|
||||||
|
|
||||||
|
const exists = game.value.player.deck.regions.hand.childIds.includes(cardId);
|
||||||
|
if(!exists) throw `卡牌"${cardId}"不在手牌中`;
|
||||||
|
|
||||||
|
const card = game.value.player.deck.cards[cardId];
|
||||||
|
const {cardData, itemId} = card;
|
||||||
|
if(!canPlayCard(game.value.player, cardData.costType, cardData.costCount, itemId, game.value.inventory)){
|
||||||
|
throw `无法支付卡牌"${cardId}"的费用`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {targetType} = cardData;
|
||||||
|
if(targetType === 'single'){
|
||||||
|
if(!targetId) throw `请指定目标`;
|
||||||
|
const target = game.value.enemies.find(e => e.id === targetId);
|
||||||
|
if(!target) throw `目标"${targetId}"不存在`;
|
||||||
|
if(!target.isAlive) throw `目标"${targetId}"已死亡`;
|
||||||
|
}else if(targetType === 'none'){
|
||||||
|
if(targetId) throw `目标"${targetId}"无效`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: 'play' as 'play',
|
||||||
|
cardId,
|
||||||
|
targetId
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
import {CombatGameContext} from "./types";
|
||||||
|
import {
|
||||||
|
addEntityEffect,
|
||||||
|
addItemEffect,
|
||||||
|
getAliveEnemies, onEntityPostureDamage,
|
||||||
|
onEntityEffectUpkeep,
|
||||||
|
onPlayerItemEffectUpkeep, onItemDiscard, onItemPlay, payCardCost
|
||||||
|
} from "@/samples/slay-the-spire-like/system/combat/effects";
|
||||||
|
import {promptMainAction} from "@/samples/slay-the-spire-like/system/combat/prompts";
|
||||||
|
import {moveToRegion, shuffle} from "@/core/region";
|
||||||
|
import {createMiddlewareChain} from "@/utils/middleware";
|
||||||
|
import {EffectData} from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
import {getAdjacentItems} from "@/samples/slay-the-spire-like/system/grid-inventory";
|
||||||
|
import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress";
|
||||||
|
|
||||||
|
type TriggerTypes = {
|
||||||
|
onCombatStart: {},
|
||||||
|
onTurnStart: { entityKey: "player" | string, },
|
||||||
|
onTurnEnd: { entityKey: "player" | string, },
|
||||||
|
onShuffle: {},
|
||||||
|
onCardPlayed: { cardId: string, targetId?: string },
|
||||||
|
onCardDiscarded: { cardId: string, },
|
||||||
|
onCardDrawn: { cardId: string, },
|
||||||
|
onDraw: {count: number},
|
||||||
|
onEffectApplied: { effect: EffectData, entityKey: "player" | string, stacks: number, cardId?: string },
|
||||||
|
onHpChange: { entityKey: "player" | string, amount: number},
|
||||||
|
onDamage: { entityKey: "player" | string, amount: number, prevented?: number},
|
||||||
|
onEnemyIntent: { enemyId: string },
|
||||||
|
onIntentUpdate: { enemyId: string },
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTriggers(){
|
||||||
|
const triggers = {
|
||||||
|
onCombatStart: createTrigger("onCombatStart"),
|
||||||
|
onTurnStart: createTrigger("onTurnStart", async ctx => {
|
||||||
|
await ctx.game.produceAsync(draft => {
|
||||||
|
const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey);
|
||||||
|
if(entity) onEntityEffectUpkeep(entity);
|
||||||
|
if(entity === draft.player)
|
||||||
|
onPlayerItemEffectUpkeep(draft.player);
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
onTurnEnd: createTrigger("onTurnEnd", async ctx => {
|
||||||
|
if(ctx.entityKey !== "player")return;
|
||||||
|
const {regions} = ctx.game.value.player.deck;
|
||||||
|
for(const cardId of Object.values(regions.hand.childIds)){
|
||||||
|
await triggers.onCardDiscarded.execute(ctx.game,{cardId});
|
||||||
|
}
|
||||||
|
await ctx.game.produceAsync(draft => draft.player.energy = draft.player.maxEnergy);
|
||||||
|
await triggers.onDraw.execute(ctx.game,{count: 5});
|
||||||
|
}),
|
||||||
|
onShuffle: createTrigger("onShuffle", async ctx => {
|
||||||
|
await ctx.game.produceAsync(draft => {
|
||||||
|
const {cards, regions} = draft.player.deck;
|
||||||
|
for(const cardId of Object.values(regions.discardPile.childIds))
|
||||||
|
moveToRegion(cards[cardId], regions.discardPile, regions.drawPile);
|
||||||
|
shuffle(regions.drawPile, cards, ctx.game.rng);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
onCardPlayed: createTrigger("onCardPlayed", async ctx => {
|
||||||
|
await ctx.game.produceAsync(draft => {
|
||||||
|
const {cards, regions} = draft.player.deck;
|
||||||
|
const card = cards[ctx.cardId];
|
||||||
|
payCardCost(draft.player, card.cardData.costType, card.cardData.costCount, card.itemId, draft.inventory);
|
||||||
|
moveToRegion(card, regions.hand, regions.discardPile);
|
||||||
|
onItemPlay(draft.player, card.itemId);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
onCardDiscarded: createTrigger("onCardDiscarded", async ctx => {
|
||||||
|
await ctx.game.produceAsync(draft => {
|
||||||
|
const {cards, regions} = draft.player.deck;
|
||||||
|
moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile);
|
||||||
|
onItemDiscard(draft.player, cards[ctx.cardId].itemId);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
onCardDrawn: createTrigger("onCardDrawn", async ctx => {
|
||||||
|
await ctx.game.produceAsync(draft => {
|
||||||
|
const {cards, regions} = draft.player.deck;
|
||||||
|
moveToRegion(cards[ctx.cardId], regions.drawPile, regions.hand);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
onDraw: createTrigger("onDraw", async ctx => {
|
||||||
|
let toDraw = ctx.count;
|
||||||
|
while(toDraw > 0){
|
||||||
|
let inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length;
|
||||||
|
if(inDraw <= 0) await triggers.onShuffle.execute(ctx.game,{});
|
||||||
|
|
||||||
|
inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length;
|
||||||
|
if(inDraw <= 0) break;
|
||||||
|
|
||||||
|
const children = ctx.game.value.player.deck.regions.drawPile.childIds;
|
||||||
|
const cardId = children[children.length - 1];
|
||||||
|
await triggers.onCardDrawn.execute(ctx.game,{cardId});
|
||||||
|
toDraw--;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
onEffectApplied: createTrigger("onEffectApplied", async ctx => {
|
||||||
|
if(ctx.effect.lifecycle === 'instant') return;
|
||||||
|
|
||||||
|
if(ctx.effect.lifecycle.startsWith("item")) {
|
||||||
|
if(ctx.cardId){
|
||||||
|
const card = ctx.game.value.player.deck.cards[ctx.cardId];
|
||||||
|
const nearby = getAdjacentItems<GameItemMeta>(ctx.game.value.inventory, card.itemId);
|
||||||
|
for(const itemId of nearby.keys()){
|
||||||
|
await ctx.game.produceAsync(draft => {
|
||||||
|
addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.game.produceAsync(draft => {
|
||||||
|
const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey);
|
||||||
|
if(entity) addEntityEffect(entity, ctx.effect, ctx.stacks);
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
onHpChange: createTrigger("onHpChange", async ctx => {
|
||||||
|
await ctx.game.produceAsync(draft => {
|
||||||
|
const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey);
|
||||||
|
if(!entity) return;
|
||||||
|
entity.hp += ctx.amount;
|
||||||
|
entity.isAlive = entity.hp > 0;
|
||||||
|
draft.result = !draft.player.isAlive ? "defeat" : draft.enemies.every(e => !e.isAlive) ? "victory" : null;
|
||||||
|
});
|
||||||
|
if(ctx.game.value.result) throw ctx.game.value;
|
||||||
|
}),
|
||||||
|
onDamage: createTrigger("onDamage", async ctx => {
|
||||||
|
const entity = ctx.entityKey === "player" ? ctx.game.value.player : ctx.game.value.enemies.find(e => e.id === ctx.entityKey);
|
||||||
|
if(!entity || !entity.isAlive) return;
|
||||||
|
const dealt = Math.min(Math.max(0,entity.hp), ctx.amount - (ctx.prevented || 0));
|
||||||
|
await ctx.game.produceAsync(draft => {
|
||||||
|
onEntityPostureDamage(entity, dealt);
|
||||||
|
});
|
||||||
|
await triggers.onHpChange.execute(ctx.game,{entityKey: ctx.entityKey, amount: -dealt});
|
||||||
|
}),
|
||||||
|
onEnemyIntent: createTrigger("onEnemyIntent", async ctx => {
|
||||||
|
const enemy = ctx.game.value.enemies.find(e => e.id === ctx.enemyId);
|
||||||
|
if(!enemy || !enemy.isAlive) return;
|
||||||
|
|
||||||
|
const intent = enemy.intents[enemy.currentIntentId];
|
||||||
|
if(!intent) return;
|
||||||
|
|
||||||
|
for(const [target, effect, stacks] of intent.effects){
|
||||||
|
if(target === 'team'){
|
||||||
|
for(const enemy of getAliveEnemies(ctx.game.value)){
|
||||||
|
await triggers.onEffectApplied.execute(ctx.game, {
|
||||||
|
effect,
|
||||||
|
entityKey: enemy.id,
|
||||||
|
stacks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}else {
|
||||||
|
const entityKey = target === 'self' ? ctx.enemyId : 'player';
|
||||||
|
await triggers.onEffectApplied.execute(ctx.game, {
|
||||||
|
effect,
|
||||||
|
entityKey,
|
||||||
|
stacks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
onIntentUpdate: createTrigger("onIntentUpdate", async ctx => {
|
||||||
|
await ctx.game.produceAsync(draft => {
|
||||||
|
const enemy = draft.enemies.find(e => e.id === ctx.enemyId);
|
||||||
|
if(!enemy) return;
|
||||||
|
|
||||||
|
const intent = enemy.intents[enemy.currentIntentId];
|
||||||
|
if(!intent) return;
|
||||||
|
|
||||||
|
const nextIntents = intent.nextIntents;
|
||||||
|
if(nextIntents.length > 0){
|
||||||
|
const nextIndex = ctx.game.rng.nextInt(nextIntents.length);
|
||||||
|
enemy.currentIntentId = nextIntents[nextIndex];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return triggers;
|
||||||
|
}
|
||||||
|
export type Triggers = ReturnType<typeof createTriggers>
|
||||||
|
export function createStartWith(build: (triggers: Triggers) => void){
|
||||||
|
const triggers = createTriggers();
|
||||||
|
build(triggers);
|
||||||
|
return async function(game: CombatGameContext){
|
||||||
|
await triggers.onCombatStart.execute(game,{});
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
await triggers.onTurnStart.execute(game, {entityKey: "player"});
|
||||||
|
while (true) {
|
||||||
|
const action = await promptMainAction(game);
|
||||||
|
if (action.action === "end-turn") break;
|
||||||
|
if (action.action === "play") {
|
||||||
|
await triggers.onCardPlayed.execute(game, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await triggers.onTurnEnd.execute(game, {entityKey: "player"});
|
||||||
|
|
||||||
|
for (const enemy of getAliveEnemies(game.value)) {
|
||||||
|
await triggers.onTurnStart.execute(game, {entityKey: enemy.id});
|
||||||
|
}
|
||||||
|
for (const enemy of getAliveEnemies(game.value)) {
|
||||||
|
await triggers.onEnemyIntent.execute(game, {enemyId: enemy.id});
|
||||||
|
await triggers.onIntentUpdate.execute(game, {enemyId: enemy.id});
|
||||||
|
}
|
||||||
|
for (const enemy of getAliveEnemies(game.value)) {
|
||||||
|
await triggers.onTurnEnd.execute(game, {entityKey: enemy.id});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
if(e === game.value) return game.value.result;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TriggerContext<TKey extends keyof TriggerTypes> = TriggerTypes[TKey] & { event: TKey, game: CombatGameContext };
|
||||||
|
function createTrigger<TKey extends keyof TriggerTypes>(event: TKey, fallback?: (ctx: TriggerContext<TKey>) => Promise<void>) {
|
||||||
|
const {use, execute} = createMiddlewareChain<TriggerContext<TKey>,void>(fallback);
|
||||||
|
return {
|
||||||
|
use,
|
||||||
|
execute: async (game: CombatGameContext, ctx: TriggerTypes[TKey]) => {
|
||||||
|
const param = {...ctx, game, event};
|
||||||
|
await execute(param);
|
||||||
|
return param;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import type { PlayerDeck } from "../deck/types";
|
||||||
|
import {EnemyData, IntentData} from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
import {EffectData} from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
import {GridInventory} from "@/samples/slay-the-spire-like/system/grid-inventory";
|
||||||
|
import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress";
|
||||||
|
|
||||||
|
export type EffectTable = Record<string, {data: EffectData, stacks: number}>;
|
||||||
|
|
||||||
|
export type CombatEntity = {
|
||||||
|
effects: EffectTable;
|
||||||
|
hp: number;
|
||||||
|
maxHp: number;
|
||||||
|
isAlive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlayerEntity = CombatEntity & {
|
||||||
|
energy: number;
|
||||||
|
maxEnergy: number;
|
||||||
|
deck: PlayerDeck;
|
||||||
|
itemEffects: Record<string, EffectTable>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnemyEntity = CombatEntity & {
|
||||||
|
id: string;
|
||||||
|
enemy: EnemyData;
|
||||||
|
intents: Record<string, IntentData>;
|
||||||
|
currentIntentId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
|
||||||
|
export type CombatResult = "victory" | "defeat";
|
||||||
|
|
||||||
|
export type LootEntry = {
|
||||||
|
type: "gold";
|
||||||
|
amount: number;
|
||||||
|
} | {
|
||||||
|
type: "item",
|
||||||
|
itemId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CombatState = {
|
||||||
|
enemies: EnemyEntity[];
|
||||||
|
player: PlayerEntity;
|
||||||
|
inventory: GridInventory<GameItemMeta>;
|
||||||
|
|
||||||
|
phase: CombatPhase;
|
||||||
|
turnNumber: number;
|
||||||
|
result: CombatResult | null;
|
||||||
|
|
||||||
|
loot: LootEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CombatGameContext = import("@/core/game").IGameContext<CombatState>;
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import {moveToRegion } from '@/core/region';
|
||||||
|
import { createRegion } from '@/core/region';
|
||||||
|
import type { GridInventory } from '../grid-inventory/types';
|
||||||
|
import type { GameItemMeta } from '../progress/types';
|
||||||
|
import type { CardData } from '../types';
|
||||||
|
import type {DeckRegions, GameCard, PlayerDeck} from './types';
|
||||||
|
|
||||||
|
function generateCardId(itemId: string, cellIndex: number): string {
|
||||||
|
return `card-${itemId}-${cellIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCard( itemId: string, cardData: CardData, cellIndex: number): GameCard {
|
||||||
|
return {
|
||||||
|
id: generateCardId(itemId, cellIndex),
|
||||||
|
regionId: '',
|
||||||
|
position: [],
|
||||||
|
itemId,
|
||||||
|
cardData
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeckRegions(): DeckRegions {
|
||||||
|
return {
|
||||||
|
drawPile: createRegion('drawPile', []),
|
||||||
|
hand: createRegion('hand', []),
|
||||||
|
discardPile: createRegion('discardPile', []),
|
||||||
|
exhaustPile: createRegion('exhaustPile', []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDeckFromInventory(inventory: GridInventory<GameItemMeta>): PlayerDeck {
|
||||||
|
const cards: Record<string, GameCard> = {};
|
||||||
|
const regions = createDeckRegions();
|
||||||
|
|
||||||
|
for (const item of inventory.items.values()) {
|
||||||
|
const itemData = item.meta?.itemData;
|
||||||
|
if (!itemData) continue;
|
||||||
|
|
||||||
|
const count = item.shape.count;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const card = createCard(item.id, itemData.card, i);
|
||||||
|
cards[card.id] = card;
|
||||||
|
moveToRegion(card, null, regions.drawPile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards,
|
||||||
|
regions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlayerDeck(): PlayerDeck {
|
||||||
|
return {
|
||||||
|
cards: {},
|
||||||
|
regions: createDeckRegions(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
generateDeckFromInventory,
|
||||||
|
createCard,
|
||||||
|
createPlayerDeck,
|
||||||
|
createDeckRegions,
|
||||||
|
generateCardId,
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types';
|
export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types';
|
||||||
export {
|
export {
|
||||||
generateDeckFromInventory,
|
generateDeckFromInventory,
|
||||||
createStatusCard,
|
createCard,
|
||||||
createDeckRegions,
|
|
||||||
createPlayerDeck,
|
createPlayerDeck,
|
||||||
generateCardId,
|
generateCardId,
|
||||||
} from './factory';
|
} from './factory';
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { Part } from '@/core/part';
|
||||||
|
import {CardData} from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
import {Region} from "@/core/region";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for a game card.
|
||||||
|
* Bridges inventory item data with the card system.
|
||||||
|
*/
|
||||||
|
export interface GameCardMeta {
|
||||||
|
cardData: CardData;
|
||||||
|
itemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card instance in the game.
|
||||||
|
* Cards are generated from inventory items or created as status effects.
|
||||||
|
*/
|
||||||
|
export type GameCard = Part<GameCardMeta>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player deck structure containing card pools.
|
||||||
|
*/
|
||||||
|
export interface PlayerDeck {
|
||||||
|
/** All cards indexed by ID */
|
||||||
|
cards: Record<string, GameCard>;
|
||||||
|
|
||||||
|
regions: DeckRegions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeckRegions{
|
||||||
|
/** Card IDs in the draw pile */
|
||||||
|
drawPile: Region;
|
||||||
|
/** Card IDs in the player's hand */
|
||||||
|
hand: Region;
|
||||||
|
/** Card IDs in the discard pile */
|
||||||
|
discardPile: Region;
|
||||||
|
/** Card IDs in the exhaust pile (removed from combat) */
|
||||||
|
exhaustPile: Region;
|
||||||
|
}
|
||||||
|
|
@ -197,7 +197,7 @@ export function getItemAtCell<TMeta = Record<string, unknown>>(
|
||||||
* Gets all items adjacent to the given item (orthogonally, not diagonally).
|
* Gets all items adjacent to the given item (orthogonally, not diagonally).
|
||||||
* Returns a Map of itemId -> item for deduplication.
|
* Returns a Map of itemId -> item for deduplication.
|
||||||
*/
|
*/
|
||||||
export function getAdjacentItems<TMeta extends Record<string, unknown> = Record<string, unknown>>(
|
export function getAdjacentItems<TMeta>(
|
||||||
inventory: GridInventory<TMeta>,
|
inventory: GridInventory<TMeta>,
|
||||||
itemId: string
|
itemId: string
|
||||||
): Map<string, InventoryItem<TMeta>> {
|
): Map<string, InventoryItem<TMeta>> {
|
||||||
|
|
@ -18,7 +18,7 @@ export interface CellCoordinate {
|
||||||
* An item placed on the grid inventory.
|
* An item placed on the grid inventory.
|
||||||
* @template TMeta - Optional metadata type for game-specific data
|
* @template TMeta - Optional metadata type for game-specific data
|
||||||
*/
|
*/
|
||||||
export interface InventoryItem<TMeta = Record<string, unknown>> {
|
export interface InventoryItem<TMeta> {
|
||||||
/** Unique item identifier */
|
/** Unique item identifier */
|
||||||
id: string;
|
id: string;
|
||||||
/** Reference to the item's shape definition */
|
/** Reference to the item's shape definition */
|
||||||
|
|
@ -44,7 +44,7 @@ export type MutationResult = { success: true } | { success: false; reason: strin
|
||||||
* Designed to be mutated directly inside a `mutative .produce()` callback.
|
* Designed to be mutated directly inside a `mutative .produce()` callback.
|
||||||
* @template TMeta - Optional metadata type for items
|
* @template TMeta - Optional metadata type for items
|
||||||
*/
|
*/
|
||||||
export interface GridInventory<TMeta = Record<string, unknown>> {
|
export interface GridInventory<TMeta> {
|
||||||
/** Board width in cells */
|
/** Board width in cells */
|
||||||
width: number;
|
width: number;
|
||||||
/** Board height in cells */
|
/** Board height in cells */
|
||||||
|
|
@ -1,16 +1,11 @@
|
||||||
import { Mulberry32RNG, type RNG } from '@/utils/rng';
|
import { ReadonlyRNG } from '@/utils/rng';
|
||||||
import encounterDesertCsvAccessor, { type EncounterDesert } from '../data/encounterDesert.csv';
|
|
||||||
import { MapNodeType, MapLayerType } from './types';
|
import { MapNodeType, MapLayerType } from './types';
|
||||||
import type { MapLayer, MapNode, PointCrawlMap, MapGenerationConfig } from './types';
|
import type { MapLayer, MapNode, PointCrawlMap, MapGenerationConfig } from './types';
|
||||||
|
import {EncounterData} from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
|
||||||
const encounterDesertCsv = encounterDesertCsvAccessor();
|
function buildEncounterIndex(src: Iterable<EncounterData>): Map<string, EncounterData[]> {
|
||||||
|
const index = new Map<string, EncounterData[]>();
|
||||||
/** Pre-indexed encounters by type */
|
for (const encounter of src) {
|
||||||
const encountersByType = buildEncounterIndex();
|
|
||||||
|
|
||||||
function buildEncounterIndex(): Map<string, EncounterDesert[]> {
|
|
||||||
const index = new Map<string, EncounterDesert[]>();
|
|
||||||
for (const encounter of encounterDesertCsv) {
|
|
||||||
const type = encounter.type;
|
const type = encounter.type;
|
||||||
if (!index.has(type)) {
|
if (!index.has(type)) {
|
||||||
index.set(type, []);
|
index.set(type, []);
|
||||||
|
|
@ -63,7 +58,7 @@ const LAYER_STRUCTURE: Array<{ layerType: MapLayerType | 'start' | 'end'; count:
|
||||||
* Fisher-Yates shuffle algorithm for unbiased random permutation.
|
* Fisher-Yates shuffle algorithm for unbiased random permutation.
|
||||||
* Mutates the array in place and returns it.
|
* Mutates the array in place and returns it.
|
||||||
*/
|
*/
|
||||||
function fisherYatesShuffle<T>(array: T[], rng: RNG): T[] {
|
function fisherYatesShuffle<T>(array: T[], rng: ReadonlyRNG): T[] {
|
||||||
for (let i = array.length - 1; i > 0; i--) {
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
const j = rng.nextInt(i + 1);
|
const j = rng.nextInt(i + 1);
|
||||||
[array[i], array[j]] = [array[j], array[i]];
|
[array[i], array[j]] = [array[j], array[i]];
|
||||||
|
|
@ -75,11 +70,7 @@ function fisherYatesShuffle<T>(array: T[], rng: RNG): T[] {
|
||||||
* Picks a random encounter for the given node type.
|
* Picks a random encounter for the given node type.
|
||||||
* Returns undefined if no matching encounter exists.
|
* Returns undefined if no matching encounter exists.
|
||||||
*/
|
*/
|
||||||
function pickEncounterForNode(type: MapNodeType, rng: RNG): EncounterDesert | undefined {
|
function pickEncounterForNode(pool: EncounterData[] | undefined, rng: ReadonlyRNG): EncounterData | undefined {
|
||||||
const encounterType = NODE_TYPE_TO_ENCOUNTER[type];
|
|
||||||
if (!encounterType) return undefined;
|
|
||||||
|
|
||||||
const pool = encountersByType.get(encounterType);
|
|
||||||
if (!pool || pool.length === 0) return undefined;
|
if (!pool || pool.length === 0) return undefined;
|
||||||
|
|
||||||
return pool[rng.nextInt(pool.length)];
|
return pool[rng.nextInt(pool.length)];
|
||||||
|
|
@ -98,12 +89,9 @@ const TOTAL_LAYERS = 10;
|
||||||
* - Each settlement layer has at least 1 of each: camp, shop, curio
|
* - Each settlement layer has at least 1 of each: camp, shop, curio
|
||||||
* - Wild nodes connect to 1 wild node or 2 settlement nodes
|
* - Wild nodes connect to 1 wild node or 2 settlement nodes
|
||||||
*
|
*
|
||||||
* @param seed Random seed for reproducibility
|
|
||||||
*/
|
*/
|
||||||
export function generatePointCrawlMap(seed?: number): PointCrawlMap {
|
export function generatePointCrawlMap(rng: ReadonlyRNG, src: Iterable<EncounterData>): PointCrawlMap {
|
||||||
const rng = new Mulberry32RNG(seed ?? Date.now());
|
const encounters = buildEncounterIndex(src);
|
||||||
const actualSeed = rng.getSeed();
|
|
||||||
|
|
||||||
const layers: MapLayer[] = [];
|
const layers: MapLayer[] = [];
|
||||||
const nodes = new Map<string, MapNode>();
|
const nodes = new Map<string, MapNode>();
|
||||||
|
|
||||||
|
|
@ -136,13 +124,13 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap {
|
||||||
for (let j = 0; j < structure.count; j++) {
|
for (let j = 0; j < structure.count; j++) {
|
||||||
const id = `node-${i}-${j}`;
|
const id = `node-${i}-${j}`;
|
||||||
const type = resolveNodeType(structure.layerType, j, structure.count, rng, wildPairTypes.get(i), j, settlementTypes, j);
|
const type = resolveNodeType(structure.layerType, j, structure.count, rng, wildPairTypes.get(i), j, settlementTypes, j);
|
||||||
const encounter = pickEncounterForNode(type, rng);
|
const encounter = pickEncounterForNode(encounters.get(NODE_TYPE_TO_ENCOUNTER[type]!), rng);
|
||||||
const node: MapNode = {
|
const node: MapNode = {
|
||||||
id,
|
id,
|
||||||
layerIndex: i,
|
layerIndex: i,
|
||||||
type,
|
type,
|
||||||
childIds: [],
|
childIds: [],
|
||||||
...(encounter ? { encounter: { name: encounter.name, description: encounter.description } } : {}),
|
encounter
|
||||||
};
|
};
|
||||||
nodes.set(id, node);
|
nodes.set(id, node);
|
||||||
nodeIds.push(id);
|
nodeIds.push(id);
|
||||||
|
|
@ -171,7 +159,7 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { layers, nodes, seed: actualSeed, parentIndex };
|
return { layers, nodes, parentIndex };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -181,7 +169,7 @@ function resolveNodeType(
|
||||||
layerType: MapLayerType | 'start' | 'end',
|
layerType: MapLayerType | 'start' | 'end',
|
||||||
_nodeIndex: number,
|
_nodeIndex: number,
|
||||||
_layerCount: number,
|
_layerCount: number,
|
||||||
rng: RNG,
|
rng: ReadonlyRNG,
|
||||||
preGeneratedTypes?: MapNodeType[],
|
preGeneratedTypes?: MapNodeType[],
|
||||||
nodeIndex?: number,
|
nodeIndex?: number,
|
||||||
settlementTypes?: MapNodeType[],
|
settlementTypes?: MapNodeType[],
|
||||||
|
|
@ -213,7 +201,7 @@ function resolveNodeType(
|
||||||
* Picks a random type for a wild node based on configured weights.
|
* Picks a random type for a wild node based on configured weights.
|
||||||
* Default: minion: 50%, elite: 25%, event: 25%
|
* Default: minion: 50%, elite: 25%, event: 25%
|
||||||
*/
|
*/
|
||||||
function pickWildNodeType(rng: RNG): MapNodeType {
|
function pickWildNodeType(rng: ReadonlyRNG): MapNodeType {
|
||||||
const weights = DEFAULT_CONFIG.wildNodeTypeWeights;
|
const weights = DEFAULT_CONFIG.wildNodeTypeWeights;
|
||||||
const roll = rng.nextInt(100);
|
const roll = rng.nextInt(100);
|
||||||
|
|
||||||
|
|
@ -226,7 +214,7 @@ function pickWildNodeType(rng: RNG): MapNodeType {
|
||||||
* Generates random types for a pair of wild layers (3 nodes each).
|
* Generates random types for a pair of wild layers (3 nodes each).
|
||||||
* Returns two arrays of 3 node types each.
|
* Returns two arrays of 3 node types each.
|
||||||
*/
|
*/
|
||||||
function generateWildPair(rng: RNG): [MapNodeType[], MapNodeType[]] {
|
function generateWildPair(rng: ReadonlyRNG): [MapNodeType[], MapNodeType[]] {
|
||||||
const layer1Types: MapNodeType[] = [];
|
const layer1Types: MapNodeType[] = [];
|
||||||
const layer2Types: MapNodeType[] = [];
|
const layer2Types: MapNodeType[] = [];
|
||||||
|
|
||||||
|
|
@ -284,7 +272,7 @@ function countRepetitions(
|
||||||
* Generates optimal wild layer pair by trying multiple attempts and selecting the one with fewest repetitions.
|
* Generates optimal wild layer pair by trying multiple attempts and selecting the one with fewest repetitions.
|
||||||
*/
|
*/
|
||||||
function generateOptimalWildPair(
|
function generateOptimalWildPair(
|
||||||
rng: RNG,
|
rng: ReadonlyRNG,
|
||||||
attempts = 3
|
attempts = 3
|
||||||
): [MapNodeType[], MapNodeType[]] {
|
): [MapNodeType[], MapNodeType[]] {
|
||||||
let bestLayer1: MapNodeType[] = [];
|
let bestLayer1: MapNodeType[] = [];
|
||||||
|
|
@ -313,7 +301,7 @@ function generateOptimalWildPair(
|
||||||
* The 4th node is randomly chosen from the three.
|
* The 4th node is randomly chosen from the three.
|
||||||
* Returns shuffled array of 4 node types.
|
* Returns shuffled array of 4 node types.
|
||||||
*/
|
*/
|
||||||
function generateSettlementTypes(rng: RNG): MapNodeType[] {
|
function generateSettlementTypes(rng: ReadonlyRNG): MapNodeType[] {
|
||||||
const requiredTypes = [MapNodeType.Camp, MapNodeType.Shop, MapNodeType.Curio];
|
const requiredTypes = [MapNodeType.Camp, MapNodeType.Shop, MapNodeType.Curio];
|
||||||
const randomType = requiredTypes[rng.nextInt(3)];
|
const randomType = requiredTypes[rng.nextInt(3)];
|
||||||
const types = [...requiredTypes, randomType];
|
const types = [...requiredTypes, randomType];
|
||||||
|
|
@ -325,7 +313,7 @@ function generateSettlementTypes(rng: RNG): MapNodeType[] {
|
||||||
* The 4th node is randomly chosen from the three.
|
* The 4th node is randomly chosen from the three.
|
||||||
* @deprecated Use generateSettlementTypes() during node creation instead.
|
* @deprecated Use generateSettlementTypes() during node creation instead.
|
||||||
*/
|
*/
|
||||||
function assignSettlementTypes(nodeIds: string[], nodes: MapNode[], rng: RNG): void {
|
function assignSettlementTypes(nodeIds: string[], nodes: MapNode[], rng: ReadonlyRNG): void {
|
||||||
// Shuffle node order to randomize which position gets which type
|
// Shuffle node order to randomize which position gets which type
|
||||||
const shuffledIndices = fisherYatesShuffle([0, 1, 2, 3], rng);
|
const shuffledIndices = fisherYatesShuffle([0, 1, 2, 3], rng);
|
||||||
|
|
||||||
|
|
@ -347,7 +335,7 @@ function generateLayerEdges(
|
||||||
sourceLayer: MapLayer,
|
sourceLayer: MapLayer,
|
||||||
targetLayer: MapLayer,
|
targetLayer: MapLayer,
|
||||||
nodes: Map<string, MapNode>,
|
nodes: Map<string, MapNode>,
|
||||||
rng: RNG
|
rng: ReadonlyRNG
|
||||||
): void {
|
): void {
|
||||||
// Settlement types are now pre-generated during node creation
|
// Settlement types are now pre-generated during node creation
|
||||||
// No need to assign them here anymore
|
// No need to assign them here anymore
|
||||||
|
|
@ -402,7 +390,7 @@ function connectWildToWild(
|
||||||
function connectWildToSettlement(
|
function connectWildToSettlement(
|
||||||
wildLayer: MapLayer,
|
wildLayer: MapLayer,
|
||||||
settlementLayer: MapLayer,
|
settlementLayer: MapLayer,
|
||||||
_rng: RNG
|
_rng: ReadonlyRNG
|
||||||
): void {
|
): void {
|
||||||
// Non-crossing connection pattern: each wild connects to 2 adjacent settlements.
|
// Non-crossing connection pattern: each wild connects to 2 adjacent settlements.
|
||||||
// Pattern: w[0]→{s[0],s[1]}, w[1]→{s[1],s[2]}, w[2]→{s[2],s[3]}
|
// Pattern: w[0]→{s[0],s[1]}, w[1]→{s[1],s[2]}, w[2]→{s[2],s[3]}
|
||||||
|
|
@ -427,7 +415,7 @@ function connectWildToSettlement(
|
||||||
function connectSettlementToWild(
|
function connectSettlementToWild(
|
||||||
settlementLayer: MapLayer,
|
settlementLayer: MapLayer,
|
||||||
wildLayer: MapLayer,
|
wildLayer: MapLayer,
|
||||||
_rng: RNG
|
_rng: ReadonlyRNG
|
||||||
): void {
|
): void {
|
||||||
// Non-crossing pattern: s0→w0, s1→w0,w1, s2→w1,w2, s3→w2
|
// Non-crossing pattern: s0→w0, s1→w0,w1, s2→w1,w2, s3→w2
|
||||||
// This pattern guarantees no crossings because when edges are sorted by
|
// This pattern guarantees no crossings because when edges are sorted by
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import {EncounterData} from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Types of nodes that can appear on the point crawl map.
|
* Types of nodes that can appear on the point crawl map.
|
||||||
*/
|
*/
|
||||||
|
|
@ -33,10 +35,7 @@ export interface MapNode {
|
||||||
/** IDs of nodes in the next layer this node connects to */
|
/** IDs of nodes in the next layer this node connects to */
|
||||||
childIds: string[];
|
childIds: string[];
|
||||||
/** Encounter data assigned to this node (from encounter CSV) */
|
/** Encounter data assigned to this node (from encounter CSV) */
|
||||||
encounter?: {
|
encounter?: EncounterData;
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -61,8 +60,6 @@ export interface PointCrawlMap {
|
||||||
layers: MapLayer[];
|
layers: MapLayer[];
|
||||||
/** All nodes keyed by ID */
|
/** All nodes keyed by ID */
|
||||||
nodes: Map<string, MapNode>;
|
nodes: Map<string, MapNode>;
|
||||||
/** RNG seed used for generation (for reproducibility) */
|
|
||||||
seed: number;
|
|
||||||
/** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */
|
/** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */
|
||||||
parentIndex?: Map<string, string[]>;
|
parentIndex?: Map<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import { Mulberry32RNG, type RNG } from '@/utils/rng';
|
import { getNode } from '../map/generator';
|
||||||
import { generatePointCrawlMap, getNode } from '../map/generator';
|
import type {MapNode, PointCrawlMap} from '../map/types';
|
||||||
import type { MapNode } from '../map/types';
|
|
||||||
import { placeItem, validatePlacement, createGridInventory, removeItem as gridRemoveItem } from '../grid-inventory/transform';
|
import { placeItem, validatePlacement, createGridInventory, removeItem as gridRemoveItem } from '../grid-inventory/transform';
|
||||||
import type { GameItem, GameItemMeta, RunMutationResult, RunState } from './types';
|
import type { GameItem, GameItemMeta, RunMutationResult, RunState } from './types';
|
||||||
import type { GridInventory } from '../grid-inventory/types';
|
import type { GridInventory } from '../grid-inventory/types';
|
||||||
import { IDENTITY_TRANSFORM, type Transform2D } from '../utils/shape-collision';
|
import { IDENTITY_TRANSFORM, type Transform2D } from '../utils/shape-collision';
|
||||||
import { parseShapeString, type ParsedShape } from '../utils/parse-shape';
|
import { parseShapeString, type ParsedShape } from '../utils/parse-shape';
|
||||||
import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv';
|
import {ItemData} from "@/samples/slay-the-spire-like/system/types";
|
||||||
import { heroItemFighter1Data } from '../data';
|
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type {
|
export type {
|
||||||
|
|
@ -28,21 +26,13 @@ const INVENTORY_HEIGHT = 4;
|
||||||
const DEFAULT_MAX_HP = 50;
|
const DEFAULT_MAX_HP = 50;
|
||||||
const DEFAULT_GOLD = 50;
|
const DEFAULT_GOLD = 50;
|
||||||
|
|
||||||
/** Starter items to give the player at the beginning of a run. */
|
|
||||||
const STARTER_ITEM_NAMES = ['治疗药剂', '绷带', '水袋', '短刀', '剑'];
|
|
||||||
|
|
||||||
// -- Run creation --
|
// -- Run creation --
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new run state with a generated map, player stats, and starter inventory.
|
* Creates a new run state with a generated map, player stats, and starter inventory.
|
||||||
*
|
*
|
||||||
* @param seed RNG seed for reproducibility. If omitted, uses current timestamp.
|
|
||||||
* @param rng Optional RNG instance for controlled randomness (overrides seed).
|
|
||||||
*/
|
*/
|
||||||
export function createRunState(seed?: number, rng?: RNG): RunState {
|
export function createRunState(map: PointCrawlMap, starterItems: ItemData[]): RunState {
|
||||||
const actualSeed = seed ?? new Mulberry32RNG().nextInt(2 ** 31);
|
|
||||||
const map = generatePointCrawlMap(actualSeed);
|
|
||||||
|
|
||||||
// Find the start node
|
// Find the start node
|
||||||
const startNode = map.layers[0].nodes[0];
|
const startNode = map.layers[0].nodes[0];
|
||||||
|
|
||||||
|
|
@ -51,10 +41,7 @@ export function createRunState(seed?: number, rng?: RNG): RunState {
|
||||||
const idCounter = { value: 0 };
|
const idCounter = { value: 0 };
|
||||||
|
|
||||||
// Place starter items
|
// Place starter items
|
||||||
for (const itemName of STARTER_ITEM_NAMES) {
|
for (const itemData of starterItems) {
|
||||||
const itemData = findItemByName(itemName);
|
|
||||||
if (!itemData) continue;
|
|
||||||
|
|
||||||
const shape = parseShapeString(itemData.shape);
|
const shape = parseShapeString(itemData.shape);
|
||||||
const itemInstance = tryPlaceItemInInventory(inventory, itemData, shape, idCounter);
|
const itemInstance = tryPlaceItemInInventory(inventory, itemData, shape, idCounter);
|
||||||
if (!itemInstance) {
|
if (!itemInstance) {
|
||||||
|
|
@ -63,7 +50,6 @@ export function createRunState(seed?: number, rng?: RNG): RunState {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
seed: actualSeed,
|
|
||||||
map,
|
map,
|
||||||
player: {
|
player: {
|
||||||
maxHp: DEFAULT_MAX_HP,
|
maxHp: DEFAULT_MAX_HP,
|
||||||
|
|
@ -215,9 +201,9 @@ export function spendGold(runState: RunState, amount: number): RunMutationResult
|
||||||
*
|
*
|
||||||
* @returns The placed item instance, or undefined if no valid position exists.
|
* @returns The placed item instance, or undefined if no valid position exists.
|
||||||
*/
|
*/
|
||||||
export function addItemFromCsv(
|
export function addItem(
|
||||||
runState: RunState,
|
runState: RunState,
|
||||||
itemData: HeroItemFighter1
|
itemData: ItemData
|
||||||
): GameItem | undefined {
|
): GameItem | undefined {
|
||||||
const shape = parseShapeString(itemData.shape);
|
const shape = parseShapeString(itemData.shape);
|
||||||
return tryPlaceItemInInventory(runState.inventory, itemData, shape, runState._idCounter);
|
return tryPlaceItemInInventory(runState.inventory, itemData, shape, runState._idCounter);
|
||||||
|
|
@ -277,13 +263,6 @@ export function isAtEndNode(runState: RunState): boolean {
|
||||||
|
|
||||||
// -- Internal helpers --
|
// -- Internal helpers --
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds a hero item by name from the CSV data.
|
|
||||||
*/
|
|
||||||
function findItemByName(name: string): HeroItemFighter1 | undefined {
|
|
||||||
return heroItemFighter1Data.find(item => item.name === name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a unique item instance ID.
|
* Generates a unique item instance ID.
|
||||||
*/
|
*/
|
||||||
|
|
@ -298,7 +277,7 @@ function generateInstanceId(counter: { value: number }): string {
|
||||||
*/
|
*/
|
||||||
function tryPlaceItemInInventory(
|
function tryPlaceItemInInventory(
|
||||||
inventory: GridInventory<GameItemMeta>,
|
inventory: GridInventory<GameItemMeta>,
|
||||||
itemData: HeroItemFighter1,
|
itemData: ItemData,
|
||||||
shape: ParsedShape,
|
shape: ParsedShape,
|
||||||
idCounter: { value: number }
|
idCounter: { value: number }
|
||||||
): GameItem | undefined {
|
): GameItem | undefined {
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { PointCrawlMap } from '../map/types';
|
import type { PointCrawlMap } from '../map/types';
|
||||||
import type { GridInventory, InventoryItem } from '../grid-inventory/types';
|
import type { GridInventory, InventoryItem } from '../grid-inventory/types';
|
||||||
import type { ParsedShape } from '../utils/parse-shape';
|
import type { ParsedShape } from '../utils/parse-shape';
|
||||||
import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv';
|
import {ItemData} from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of an encounter (combat, event, etc.).
|
* Result of an encounter (combat, event, etc.).
|
||||||
|
|
@ -35,9 +35,11 @@ export interface EncounterState {
|
||||||
*/
|
*/
|
||||||
export interface GameItemMeta {
|
export interface GameItemMeta {
|
||||||
/** Original CSV item data */
|
/** Original CSV item data */
|
||||||
itemData: HeroItemFighter1;
|
itemData: ItemData;
|
||||||
/** Parsed shape for grid placement */
|
/** Parsed shape for grid placement */
|
||||||
shape: ParsedShape;
|
shape: ParsedShape;
|
||||||
|
/** Consumed uses, if card cost type is uses**/
|
||||||
|
depletion?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -63,8 +65,6 @@ export interface PlayerState {
|
||||||
* Designed to be used inside `MutableSignal.produce()` callbacks.
|
* Designed to be used inside `MutableSignal.produce()` callbacks.
|
||||||
*/
|
*/
|
||||||
export interface RunState {
|
export interface RunState {
|
||||||
/** RNG seed used for map generation */
|
|
||||||
seed: number;
|
|
||||||
/** Generated point crawl map */
|
/** Generated point crawl map */
|
||||||
map: PointCrawlMap;
|
map: PointCrawlMap;
|
||||||
/** Player HP and gold */
|
/** Player HP and gold */
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
export type EffectData = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly lifecycle: EffectLifecycle;
|
||||||
|
};
|
||||||
|
export type EffectLifecycle = "instant" | "temporary" | "lingering" | "permanent" | "posture" | "item" | "itemTemporary" | "itemUntilPlay" | "itemUntilDiscard" | "itemPermanent";
|
||||||
|
|
||||||
|
export type EnemyData = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CardType = "item" | "status";
|
||||||
|
export type CardCostType = "energy" | "uses" | "none";
|
||||||
|
export type CardTargetType = "single" | "none";
|
||||||
|
export type EffectTarget = "self" | "player" | "team";
|
||||||
|
|
||||||
|
export type CardData = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly desc: string;
|
||||||
|
readonly type: CardType;
|
||||||
|
readonly costType: CardCostType;
|
||||||
|
readonly costCount: number;
|
||||||
|
readonly targetType: CardTargetType;
|
||||||
|
readonly effects: readonly [CardEffectTrigger, CardEffectTarget, EffectData, number][];
|
||||||
|
};
|
||||||
|
export type CardEffectTrigger = "onPlay" | "onDraw" | "onDiscard";
|
||||||
|
export type CardEffectTarget = "self" | "target" | "all" | "random"
|
||||||
|
|
||||||
|
export type EncounterType = "minion" | "elite" | "event" | "shop" | "camp" | "curio";
|
||||||
|
export type EncounterData = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type: EncounterType;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly enemies: readonly [EnemyData, number, readonly [effect: EffectData, stacks: number]];
|
||||||
|
readonly dialogue: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IntentData = {
|
||||||
|
readonly enemy: EnemyData;
|
||||||
|
readonly intentId: string;
|
||||||
|
readonly initialIntent: boolean;
|
||||||
|
readonly nextIntents: readonly string[];
|
||||||
|
readonly brokenIntent: readonly string[];
|
||||||
|
readonly initBuffs: readonly [EffectData, stacks: number];
|
||||||
|
readonly effects: readonly [EffectTarget, EffectData, number][];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ItemData = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly shape: string;
|
||||||
|
readonly card: CardData;
|
||||||
|
readonly price: number;
|
||||||
|
readonly description: string;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
type Middleware<TContext, TReturn> = (
|
||||||
|
context: TContext,
|
||||||
|
next: () => Promise<TReturn>
|
||||||
|
) => Promise<TReturn>;
|
||||||
|
|
||||||
|
export type MiddlewareChain<TContext, TReturn> = {
|
||||||
|
use: (middleware: Middleware<TContext, TReturn>) => void;
|
||||||
|
execute: (context: TContext) => Promise<TReturn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createMiddlewareChain<TContext extends object, TReturn=TContext>(
|
||||||
|
fallback?: (context: TContext) => Promise<TReturn>
|
||||||
|
): MiddlewareChain<TContext, TReturn> {
|
||||||
|
const middlewares: Middleware<TContext, TReturn>[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
use(middleware: Middleware<TContext, TReturn>) {
|
||||||
|
middlewares.push(middleware);
|
||||||
|
},
|
||||||
|
async execute(context: TContext) {
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
async function dispatch(ctx: TContext): Promise<TReturn> {
|
||||||
|
if (index >= middlewares.length) {
|
||||||
|
return fallback ? fallback(ctx) : ctx as unknown as TReturn;
|
||||||
|
}
|
||||||
|
const current = middlewares[index++];
|
||||||
|
return current(ctx, () => dispatch(ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dispatch(context);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,344 +1,550 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
applyDamage,
|
addEffect,
|
||||||
applyDefend,
|
addEntityEffect,
|
||||||
applyBuff,
|
addItemEffect,
|
||||||
removeBuff,
|
onEntityEffectUpkeep,
|
||||||
updateBuffs,
|
onEntityPostureDamage,
|
||||||
|
onPlayerItemEffectUpkeep,
|
||||||
|
onItemPlay,
|
||||||
|
onItemDiscard,
|
||||||
|
getAliveEnemies,
|
||||||
|
getCombatEntity,
|
||||||
canPlayCard,
|
canPlayCard,
|
||||||
playCard,
|
payCardCost,
|
||||||
areAllEnemiesDead,
|
} from '@/samples/slay-the-spire-like/system/combat/effects';
|
||||||
isPlayerDead,
|
import type { CombatEntity, CombatState, EffectTable, PlayerEntity, EnemyEntity } from '@/samples/slay-the-spire-like/system/combat/types';
|
||||||
getModifiedAttackDamage,
|
import type { EffectData } from '@/samples/slay-the-spire-like/system/types';
|
||||||
getModifiedDefendAmount,
|
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory/types';
|
||||||
} from '@/samples/slay-the-spire-like/combat/effects';
|
import type { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types';
|
||||||
import type { CombatState, PlayerCombatState, EnemyState } from '@/samples/slay-the-spire-like/combat/types';
|
import type { ParsedShape } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
|
||||||
import {
|
import type { Transform2D } from '@/samples/slay-the-spire-like/system/utils/shape-collision';
|
||||||
createCombatState,
|
|
||||||
createEnemyInstance,
|
|
||||||
createPlayerCombatState,
|
|
||||||
drawCardsToHand,
|
|
||||||
} from '@/samples/slay-the-spire-like/combat/state';
|
|
||||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
|
||||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
|
||||||
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
|
|
||||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
|
||||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
|
||||||
import { enemyDesertData, encounterDesertData } from '@/samples/slay-the-spire-like/data';
|
|
||||||
import { Mulberry32RNG } from '@/utils/rng';
|
|
||||||
|
|
||||||
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
|
function createEffect(id: string, lifecycle: EffectData['lifecycle']): EffectData {
|
||||||
const shape = parseShapeString(shapeStr);
|
return { id, name: id, description: '', lifecycle };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCard(id: string, costType: 'energy' | 'uses' | 'none', costCount: number) {
|
||||||
|
return { id, name: id, desc: '', type: 'item' as const, costType, costCount, targetType: 'none' as const, effects: [] as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createItem(itemId: string, cardId: string, costType: 'energy' | 'uses' | 'none', costCount: number, depletion = 0): InventoryItem<GameItemMeta> {
|
||||||
return {
|
return {
|
||||||
itemData: {
|
id: itemId,
|
||||||
type: 'weapon',
|
shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
|
||||||
name,
|
transform: { x: 0, y: 0, rotation: 0, flipX: false, flipY: false } as unknown as Transform2D,
|
||||||
shape: shapeStr,
|
meta: {
|
||||||
costType: 'energy',
|
itemData: { id: itemId, type: 'weapon', name: itemId, shape: '1x1', card: createCard(cardId, costType, costCount), price: 0, description: '' },
|
||||||
costCount: 1,
|
shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
|
||||||
targetType: 'single',
|
depletion: costType === 'uses' ? depletion : undefined,
|
||||||
price: 10,
|
|
||||||
desc: '测试',
|
|
||||||
},
|
},
|
||||||
shape,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTestInventory(): GridInventory<GameItemMeta> {
|
function createInventory(items: InventoryItem<GameItemMeta>[]): GridInventory<GameItemMeta> {
|
||||||
const inv = createGridInventory<GameItemMeta>(6, 4);
|
const map = new Map<string, InventoryItem<GameItemMeta>>();
|
||||||
const meta1 = createTestMeta('短刀', 'oe');
|
const occupied = new Set<string>();
|
||||||
const item1: InventoryItem<GameItemMeta> = {
|
for (const item of items) {
|
||||||
id: 'item-1',
|
map.set(item.id, item);
|
||||||
shape: meta1.shape,
|
occupied.add(`${item.transform.x},${item.transform.y}`);
|
||||||
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
}
|
||||||
meta: meta1,
|
return { width: 6, height: 4, items: map, occupiedCells: occupied };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCombatEntity(hp = 10, maxHp = 10): CombatEntity {
|
||||||
|
return {
|
||||||
|
effects: {},
|
||||||
|
hp,
|
||||||
|
maxHp,
|
||||||
|
isAlive: hp > 0,
|
||||||
};
|
};
|
||||||
placeItem(inv, item1);
|
|
||||||
return inv;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTestCombatState(): CombatState {
|
function createPlayerEntity(hp = 30, maxHp = 30): PlayerEntity {
|
||||||
const inv = createTestInventory();
|
return {
|
||||||
const playerState: PlayerState = { maxHp: 50, currentHp: 50, gold: 0 };
|
...createCombatEntity(hp, maxHp),
|
||||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
energy: 3,
|
||||||
return createCombatState(playerState, inv, encounter);
|
maxEnergy: 3,
|
||||||
|
deck: { cards: {}, regions: { drawPile: { id: 'drawPile', axes: [], childIds: [], partMap: {} }, hand: { id: 'hand', axes: [], childIds: [], partMap: {} }, discardPile: { id: 'discardPile', axes: [], childIds: [], partMap: {} }, exhaustPile: { id: 'exhaustPile', axes: [], childIds: [], partMap: {} } } },
|
||||||
|
itemEffects: {},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSimpleRng() {
|
function createEnemyEntity(id: string, hp = 10, maxHp = 10): EnemyEntity {
|
||||||
return new Mulberry32RNG(42);
|
return {
|
||||||
|
...createCombatEntity(hp, maxHp),
|
||||||
|
id,
|
||||||
|
enemy: { id, name: id, description: '' },
|
||||||
|
intents: {},
|
||||||
|
currentIntentId: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCombatState(playerHp = 30, enemies: EnemyEntity[] = []): CombatState {
|
||||||
|
return {
|
||||||
|
player: createPlayerEntity(playerHp),
|
||||||
|
enemies,
|
||||||
|
inventory: { width: 6, height: 4, items: new Map(), occupiedCells: new Set() },
|
||||||
|
phase: 'playerTurn',
|
||||||
|
turnNumber: 1,
|
||||||
|
result: null,
|
||||||
|
loot: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('combat/effects', () => {
|
describe('combat/effects', () => {
|
||||||
describe('applyDamage', () => {
|
describe('addEffect', () => {
|
||||||
it('should deal damage to player', () => {
|
it('should add a new effect to an empty table', () => {
|
||||||
const state = createTestCombatState();
|
const table: EffectTable = {};
|
||||||
applyDamage(state, 'player', 10);
|
const effect = createEffect('strength', 'temporary');
|
||||||
|
|
||||||
expect(state.player.hp).toBe(40);
|
addEffect(table, effect, 3);
|
||||||
expect(state.player.damageTakenThisTurn).toBe(10);
|
|
||||||
expect(state.player.damagedThisTurn).toBe(true);
|
expect(table['strength']).toBeDefined();
|
||||||
|
expect(table['strength'].data).toBe(effect);
|
||||||
|
expect(table['strength'].stacks).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should deal damage to enemy', () => {
|
it('should stack with existing effect of same id', () => {
|
||||||
const state = createTestCombatState();
|
const table: EffectTable = {};
|
||||||
const enemyId = state.enemyOrder[0];
|
const effect = createEffect('strength', 'lingering');
|
||||||
const enemy = state.enemies[enemyId];
|
|
||||||
const initialHp = enemy.hp;
|
|
||||||
|
|
||||||
applyDamage(state, enemyId, 5);
|
addEffect(table, effect, 2);
|
||||||
|
addEffect(table, effect, 3);
|
||||||
|
|
||||||
expect(enemy.hp).toBe(initialHp - 5);
|
expect(table['strength'].stacks).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be absorbed by defend buff on player', () => {
|
it('should remove effect when stacks reach 0', () => {
|
||||||
const state = createTestCombatState();
|
const table: EffectTable = {};
|
||||||
state.player.buffs['defend'] = 3;
|
const effect = createEffect('strength', 'temporary');
|
||||||
|
|
||||||
const result = applyDamage(state, 'player', 5);
|
addEffect(table, effect, 3);
|
||||||
|
addEffect(table, effect, -3);
|
||||||
|
|
||||||
expect(result.blockedByDefend).toBe(3);
|
expect(table['strength']).toBeUndefined();
|
||||||
expect(result.damageDealt).toBe(2);
|
|
||||||
expect(state.player.hp).toBe(48);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be fully absorbed by defend buff', () => {
|
it('should not add effect when stacks is 0', () => {
|
||||||
const state = createTestCombatState();
|
const table: EffectTable = {};
|
||||||
state.player.buffs['defend'] = 10;
|
const effect = createEffect('strength', 'temporary');
|
||||||
|
|
||||||
applyDamage(state, 'player', 5);
|
addEffect(table, effect, 0);
|
||||||
|
|
||||||
expect(state.player.hp).toBe(50);
|
expect(table['strength']).toBeUndefined();
|
||||||
expect(state.player.buffs['defend']).toBe(5);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be absorbed by defend buff on enemy', () => {
|
it('should handle negative stacks', () => {
|
||||||
const state = createTestCombatState();
|
const table: EffectTable = {};
|
||||||
const enemyId = state.enemyOrder[0];
|
const effect = createEffect('weak', 'temporary');
|
||||||
state.enemies[enemyId].buffs['defend'] = 4;
|
|
||||||
|
|
||||||
const result = applyDamage(state, enemyId, 6);
|
addEffect(table, effect, -2);
|
||||||
|
|
||||||
expect(result.blockedByDefend).toBe(4);
|
expect(table['weak'].stacks).toBe(-2);
|
||||||
expect(result.damageDealt).toBe(2);
|
|
||||||
expect(state.enemies[enemyId].hp).toBe(state.enemies[enemyId].maxHp - 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should mark defend broken when defend fully consumed', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const enemyId = state.enemyOrder[0];
|
|
||||||
state.enemies[enemyId].buffs['defend'] = 3;
|
|
||||||
|
|
||||||
applyDamage(state, enemyId, 5);
|
|
||||||
|
|
||||||
expect(state.enemies[enemyId].hadDefendBroken).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should kill enemy when HP reaches 0', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const enemyId = state.enemyOrder[0];
|
|
||||||
|
|
||||||
applyDamage(state, enemyId, state.enemies[enemyId].maxHp);
|
|
||||||
|
|
||||||
expect(state.enemies[enemyId].isAlive).toBe(false);
|
|
||||||
expect(state.enemies[enemyId].hp).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not deal negative damage', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
|
|
||||||
const result = applyDamage(state, 'player', -5);
|
|
||||||
|
|
||||||
expect(result.damageDealt).toBe(0);
|
|
||||||
expect(state.player.hp).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply damageReduce buff', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
state.player.buffs['damageReduce'] = 3;
|
|
||||||
|
|
||||||
applyDamage(state, 'player', 5);
|
|
||||||
|
|
||||||
expect(state.player.hp).toBe(48);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('applyDefend', () => {
|
describe('addEntityEffect', () => {
|
||||||
it('should add defend stacks', () => {
|
it('should add effect to entity.effects', () => {
|
||||||
const buffs: Record<string, number> = {};
|
const entity = createCombatEntity();
|
||||||
applyDefend(buffs, 5);
|
const effect = createEffect('vulnerable', 'lingering');
|
||||||
|
|
||||||
expect(buffs['defend']).toBe(5);
|
addEntityEffect(entity, effect, 2);
|
||||||
});
|
|
||||||
|
|
||||||
it('should stack with existing defend', () => {
|
expect(entity.effects['vulnerable'].stacks).toBe(2);
|
||||||
const buffs: Record<string, number> = { defend: 3 };
|
|
||||||
applyDefend(buffs, 4);
|
|
||||||
|
|
||||||
expect(buffs['defend']).toBe(7);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('applyBuff / removeBuff', () => {
|
describe('addItemEffect', () => {
|
||||||
it('should apply buff stacks', () => {
|
it('should add effect to player.itemEffects[itemKey]', () => {
|
||||||
const buffs: Record<string, number> = {};
|
const player = createPlayerEntity();
|
||||||
applyBuff(buffs, 'aim', 'lingering', 3);
|
const effect = createEffect('adjacent-buff', 'itemTemporary');
|
||||||
|
|
||||||
expect(buffs['aim']).toBe(3);
|
addItemEffect(player, 'sword-1', effect, 3);
|
||||||
|
|
||||||
|
expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should stack existing buffs', () => {
|
it('should initialize itemEffects entry if not present', () => {
|
||||||
const buffs: Record<string, number> = { aim: 2 };
|
const player = createPlayerEntity();
|
||||||
applyBuff(buffs, 'aim', 'lingering', 3);
|
const effect = createEffect('adjacent-buff', 'itemTemporary');
|
||||||
|
|
||||||
expect(buffs['aim']).toBe(5);
|
addItemEffect(player, 'new-item', effect, 1);
|
||||||
|
|
||||||
|
expect(player.itemEffects['new-item']).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove buff partially', () => {
|
it('should stack with existing item effect', () => {
|
||||||
const buffs: Record<string, number> = { aim: 5 };
|
const player = createPlayerEntity();
|
||||||
const removed = removeBuff(buffs, 'aim', 3);
|
const effect = createEffect('adjacent-buff', 'itemTemporary');
|
||||||
|
|
||||||
expect(removed).toBe(3);
|
addItemEffect(player, 'sword-1', effect, 2);
|
||||||
expect(buffs['aim']).toBe(2);
|
addItemEffect(player, 'sword-1', effect, 3);
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove buff fully when stacks exceed current', () => {
|
expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(5);
|
||||||
const buffs: Record<string, number> = { aim: 2 };
|
|
||||||
const removed = removeBuff(buffs, 'aim', 10);
|
|
||||||
|
|
||||||
expect(removed).toBe(2);
|
|
||||||
expect(buffs['aim']).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateBuffs', () => {
|
describe('onEntityEffectUpkeep', () => {
|
||||||
it('should clear temporary buffs', () => {
|
it('should remove temporary effects', () => {
|
||||||
const buffs: Record<string, number> = { damageReduce: 3, defendNext: 2 };
|
const entity = createCombatEntity();
|
||||||
updateBuffs(buffs);
|
const tempEffect = createEffect('temp-shield', 'temporary');
|
||||||
|
|
||||||
expect(buffs['damageReduce']).toBeUndefined();
|
addEntityEffect(entity, tempEffect, 5);
|
||||||
expect(buffs['defendNext']).toBeUndefined();
|
onEntityEffectUpkeep(entity);
|
||||||
|
|
||||||
|
expect(entity.effects['temp-shield']).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should decrement lingering buffs', () => {
|
it('should decrement lingering effects by 1', () => {
|
||||||
const buffs: Record<string, number> = { curse: 3, energyDrain: 1 };
|
const entity = createCombatEntity();
|
||||||
updateBuffs(buffs);
|
const lingeringEffect = createEffect('poison', 'lingering');
|
||||||
|
|
||||||
expect(buffs['curse']).toBe(2);
|
addEntityEffect(entity, lingeringEffect, 3);
|
||||||
expect(buffs['energyDrain']).toBeUndefined();
|
onEntityEffectUpkeep(entity);
|
||||||
|
|
||||||
|
expect(entity.effects['poison'].stacks).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not affect permanent or posture buffs', () => {
|
it('should remove lingering effects when stacks reach 0', () => {
|
||||||
const buffs: Record<string, number> = { defend: 5, spike: 1 };
|
const entity = createCombatEntity();
|
||||||
updateBuffs(buffs);
|
const lingeringEffect = createEffect('poison', 'lingering');
|
||||||
|
|
||||||
expect(buffs['defend']).toBe(5);
|
addEntityEffect(entity, lingeringEffect, 1);
|
||||||
expect(buffs['spike']).toBe(1);
|
onEntityEffectUpkeep(entity);
|
||||||
|
|
||||||
|
expect(entity.effects['poison']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not affect permanent effects', () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const permEffect = createEffect('max-hp-up', 'permanent');
|
||||||
|
|
||||||
|
addEntityEffect(entity, permEffect, 5);
|
||||||
|
onEntityEffectUpkeep(entity);
|
||||||
|
|
||||||
|
expect(entity.effects['max-hp-up'].stacks).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not affect instant effects', () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const instantEffect = createEffect('instant-damage', 'instant');
|
||||||
|
|
||||||
|
addEntityEffect(entity, instantEffect, 10);
|
||||||
|
onEntityEffectUpkeep(entity);
|
||||||
|
|
||||||
|
expect(entity.effects['instant-damage'].stacks).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increment lingering effects with negative stacks', () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const lingeringEffect = createEffect('regen', 'lingering');
|
||||||
|
|
||||||
|
addEntityEffect(entity, lingeringEffect, -3);
|
||||||
|
onEntityEffectUpkeep(entity);
|
||||||
|
|
||||||
|
expect(entity.effects['regen'].stacks).toBe(-2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onEntityPostureDamage', () => {
|
||||||
|
it('should reduce posture effects by damage amount', () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const postureEffect = createEffect('block', 'posture');
|
||||||
|
|
||||||
|
addEntityEffect(entity, postureEffect, 10);
|
||||||
|
onEntityPostureDamage(entity, 4);
|
||||||
|
|
||||||
|
expect(entity.effects['block'].stacks).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not reduce posture effects below 0', () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const postureEffect = createEffect('block', 'posture');
|
||||||
|
|
||||||
|
addEntityEffect(entity, postureEffect, 3);
|
||||||
|
onEntityPostureDamage(entity, 10);
|
||||||
|
|
||||||
|
expect(entity.effects['block']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not affect non-posture effects', () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const postureEffect = createEffect('block', 'posture');
|
||||||
|
const permEffect = createEffect('strength', 'permanent');
|
||||||
|
|
||||||
|
addEntityEffect(entity, postureEffect, 5);
|
||||||
|
addEntityEffect(entity, permEffect, 3);
|
||||||
|
onEntityPostureDamage(entity, 2);
|
||||||
|
|
||||||
|
expect(entity.effects['block'].stacks).toBe(3);
|
||||||
|
expect(entity.effects['strength'].stacks).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero damage', () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const postureEffect = createEffect('block', 'posture');
|
||||||
|
|
||||||
|
addEntityEffect(entity, postureEffect, 5);
|
||||||
|
onEntityPostureDamage(entity, 0);
|
||||||
|
|
||||||
|
expect(entity.effects['block'].stacks).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onPlayerItemEffectUpkeep', () => {
|
||||||
|
it('should remove itemTemporary effects', () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const effect = createEffect('adjacent-buff', 'itemTemporary');
|
||||||
|
|
||||||
|
addItemEffect(player, 'sword-1', effect, 5);
|
||||||
|
onPlayerItemEffectUpkeep(player);
|
||||||
|
|
||||||
|
expect(player.itemEffects['sword-1']['adjacent-buff']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not affect itemPermanent effects', () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const effect = createEffect('adjacent-buff', 'itemPermanent');
|
||||||
|
|
||||||
|
addItemEffect(player, 'sword-1', effect, 5);
|
||||||
|
onPlayerItemEffectUpkeep(player);
|
||||||
|
|
||||||
|
expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not affect itemUntilPlay effects', () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const effect = createEffect('charged', 'itemUntilPlay');
|
||||||
|
|
||||||
|
addItemEffect(player, 'sword-1', effect, 3);
|
||||||
|
onPlayerItemEffectUpkeep(player);
|
||||||
|
|
||||||
|
expect(player.itemEffects['sword-1']['charged'].stacks).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onItemPlay', () => {
|
||||||
|
it('should remove itemUntilPlay effects', () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const effect = createEffect('charged', 'itemUntilPlay');
|
||||||
|
|
||||||
|
addItemEffect(player, 'sword-1', effect, 3);
|
||||||
|
onItemPlay(player, 'sword-1');
|
||||||
|
|
||||||
|
expect(player.itemEffects['sword-1']['charged']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not affect other lifecycle effects', () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const permEffect = createEffect('passive', 'itemPermanent');
|
||||||
|
const playEffect = createEffect('charged', 'itemUntilPlay');
|
||||||
|
|
||||||
|
addItemEffect(player, 'sword-1', permEffect, 5);
|
||||||
|
addItemEffect(player, 'sword-1', playEffect, 3);
|
||||||
|
onItemPlay(player, 'sword-1');
|
||||||
|
|
||||||
|
expect(player.itemEffects['sword-1']['passive'].stacks).toBe(5);
|
||||||
|
expect(player.itemEffects['sword-1']['charged']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing for item with no effects', () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
|
||||||
|
expect(() => onItemPlay(player, 'nonexistent')).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onItemDiscard', () => {
|
||||||
|
it('should remove itemUntilDiscard effects', () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const effect = createEffect('discard-buff', 'itemUntilDiscard');
|
||||||
|
|
||||||
|
addItemEffect(player, 'sword-1', effect, 3);
|
||||||
|
onItemDiscard(player, 'sword-1');
|
||||||
|
|
||||||
|
expect(player.itemEffects['sword-1']['discard-buff']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not affect other lifecycle effects', () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const permEffect = createEffect('passive', 'itemPermanent');
|
||||||
|
const discardEffect = createEffect('discard-buff', 'itemUntilDiscard');
|
||||||
|
|
||||||
|
addItemEffect(player, 'sword-1', permEffect, 5);
|
||||||
|
addItemEffect(player, 'sword-1', discardEffect, 3);
|
||||||
|
onItemDiscard(player, 'sword-1');
|
||||||
|
|
||||||
|
expect(player.itemEffects['sword-1']['passive'].stacks).toBe(5);
|
||||||
|
expect(player.itemEffects['sword-1']['discard-buff']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing for item with no effects', () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
|
||||||
|
expect(() => onItemDiscard(player, 'nonexistent')).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAliveEnemies', () => {
|
||||||
|
it('should yield only alive enemies', () => {
|
||||||
|
const state = createCombatState(30, [
|
||||||
|
createEnemyEntity('slime-1', 10, 10),
|
||||||
|
createEnemyEntity('slime-2', 0, 10),
|
||||||
|
createEnemyEntity('slime-3', 5, 10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const alive = [...getAliveEnemies(state)];
|
||||||
|
|
||||||
|
expect(alive.length).toBe(2);
|
||||||
|
expect(alive[0].id).toBe('slime-1');
|
||||||
|
expect(alive[1].id).toBe('slime-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty for no enemies', () => {
|
||||||
|
const state = createCombatState(30, []);
|
||||||
|
|
||||||
|
const alive = [...getAliveEnemies(state)];
|
||||||
|
|
||||||
|
expect(alive.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty when all enemies are dead', () => {
|
||||||
|
const state = createCombatState(30, [
|
||||||
|
createEnemyEntity('slime-1', 0, 10),
|
||||||
|
createEnemyEntity('slime-2', 0, 10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const alive = [...getAliveEnemies(state)];
|
||||||
|
|
||||||
|
expect(alive.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCombatEntity', () => {
|
||||||
|
it('should return player for "player" key', () => {
|
||||||
|
const state = createCombatState(30);
|
||||||
|
|
||||||
|
const entity = getCombatEntity(state, 'player');
|
||||||
|
|
||||||
|
expect(entity).toBe(state.player);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return enemy by id', () => {
|
||||||
|
const enemy = createEnemyEntity('boss-1', 50, 50);
|
||||||
|
const state = createCombatState(30, [enemy]);
|
||||||
|
|
||||||
|
const entity = getCombatEntity(state, 'boss-1');
|
||||||
|
|
||||||
|
expect(entity).toBe(enemy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for non-existent enemy', () => {
|
||||||
|
const state = createCombatState(30, [createEnemyEntity('slime-1')]);
|
||||||
|
|
||||||
|
const entity = getCombatEntity(state, 'nonexistent');
|
||||||
|
|
||||||
|
expect(entity).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('canPlayCard', () => {
|
describe('canPlayCard', () => {
|
||||||
it('should allow playing card with enough energy', () => {
|
it('should allow playing energy card when player has enough energy', () => {
|
||||||
const state = createTestCombatState();
|
const player = createPlayerEntity();
|
||||||
const cardId = state.player.deck.hand[0];
|
player.energy = 3;
|
||||||
|
const inventory = createInventory([]);
|
||||||
|
|
||||||
const result = canPlayCard(state, cardId);
|
const result = canPlayCard(player, 'energy', 2, 'any', inventory);
|
||||||
expect(result.canPlay).toBe(true);
|
|
||||||
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject card not in hand', () => {
|
it('should reject playing energy card when player lacks energy', () => {
|
||||||
const state = createTestCombatState();
|
const player = createPlayerEntity();
|
||||||
|
player.energy = 1;
|
||||||
|
const inventory = createInventory([]);
|
||||||
|
|
||||||
const result = canPlayCard(state, 'nonexistent-card');
|
const result = canPlayCard(player, 'energy', 2, 'any', inventory);
|
||||||
expect(result.canPlay).toBe(false);
|
|
||||||
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject card with insufficient energy', () => {
|
it('should allow playing uses card when item has remaining uses', () => {
|
||||||
const state = createTestCombatState();
|
const player = createPlayerEntity();
|
||||||
state.player.energy = 0;
|
const item = createItem('potion-1', 'potion-card', 'uses', 3, 1);
|
||||||
const cardId = state.player.deck.hand[0];
|
const inventory = createInventory([item]);
|
||||||
|
|
||||||
const card = state.player.deck.cards[cardId];
|
const result = canPlayCard(player, 'uses', 3, 'potion-1', inventory);
|
||||||
if (card?.itemData?.costType === 'energy' && card.itemData.costCount > 0) {
|
|
||||||
const result = canPlayCard(state, cardId);
|
expect(result).toBe(true);
|
||||||
expect(result.canPlay).toBe(false);
|
});
|
||||||
expect(result.reason).toBe('能量不足');
|
|
||||||
}
|
it('should reject playing uses card when item is depleted', () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const item = createItem('potion-1', 'potion-card', 'uses', 3, 3);
|
||||||
|
const inventory = createInventory([item]);
|
||||||
|
|
||||||
|
const result = canPlayCard(player, 'uses', 3, 'potion-1', inventory);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject playing uses card when item not in inventory', () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const inventory = createInventory([]);
|
||||||
|
|
||||||
|
const result = canPlayCard(player, 'uses', 1, 'missing', inventory);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should always allow playing none cost card', () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
player.energy = 0;
|
||||||
|
const inventory = createInventory([]);
|
||||||
|
|
||||||
|
const result = canPlayCard(player, 'none', 0, 'any', inventory);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('playCard', () => {
|
describe('payCardCost', () => {
|
||||||
it('should deduct energy cost when playing card', () => {
|
it('should deduct energy for energy cost card', () => {
|
||||||
const state = createTestCombatState();
|
const player = createPlayerEntity();
|
||||||
const cardId = state.player.deck.hand[0];
|
player.energy = 3;
|
||||||
const card = state.player.deck.cards[cardId];
|
const inventory = createInventory([]);
|
||||||
const initialEnergy = state.player.energy;
|
|
||||||
|
|
||||||
if (card?.itemData?.costType === 'energy') {
|
payCardCost(player, 'energy', 2, 'any', inventory);
|
||||||
const ctx = { state, rng: createSimpleRng() };
|
|
||||||
const result = playCard(ctx, cardId);
|
|
||||||
|
|
||||||
if (result.success) {
|
expect(player.energy).toBe(1);
|
||||||
expect(state.player.energy).toBe(initialEnergy - card.itemData.costCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should move card to discard pile after playing', () => {
|
it('should increment depletion for uses cost card', () => {
|
||||||
const state = createTestCombatState();
|
const player = createPlayerEntity();
|
||||||
const cardId = state.player.deck.hand[0];
|
const item = createItem('potion-1', 'potion-card', 'uses', 3, 1);
|
||||||
const ctx = { state, rng: createSimpleRng() };
|
const inventory = createInventory([item]);
|
||||||
|
|
||||||
const result = playCard(ctx, cardId);
|
payCardCost(player, 'uses', 3, 'potion-1', inventory);
|
||||||
|
|
||||||
if (result.success) {
|
expect(item.meta?.depletion).toBe(4);
|
||||||
expect(state.player.deck.hand.includes(cardId)).toBe(false);
|
|
||||||
expect(state.player.deck.discardPile.includes(cardId) || state.player.deck.exhaustPile.includes(cardId)).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('areAllEnemiesDead / isPlayerDead', () => {
|
|
||||||
it('should detect all enemies dead', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
expect(areAllEnemiesDead(state)).toBe(false);
|
|
||||||
|
|
||||||
for (const enemyId of state.enemyOrder) {
|
|
||||||
state.enemies[enemyId].isAlive = false;
|
|
||||||
}
|
|
||||||
expect(areAllEnemiesDead(state)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect player death', () => {
|
it('should do nothing for none cost card', () => {
|
||||||
const state = createTestCombatState();
|
const player = createPlayerEntity();
|
||||||
expect(isPlayerDead(state)).toBe(false);
|
player.energy = 3;
|
||||||
|
const inventory = createInventory([]);
|
||||||
|
|
||||||
state.player.hp = 0;
|
payCardCost(player, 'none', 0, 'any', inventory);
|
||||||
expect(isPlayerDead(state)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getModifiedAttackDamage / getModifiedDefendAmount', () => {
|
expect(player.energy).toBe(3);
|
||||||
it('should return base damage with no item buffs', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
expect(getModifiedAttackDamage(state, 'some-card', 5)).toBe(5);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return base defend with no item buffs', () => {
|
it('should handle missing item gracefully for uses cost', () => {
|
||||||
const state = createTestCombatState();
|
const player = createPlayerEntity();
|
||||||
expect(getModifiedDefendAmount(state, 'some-card', 4)).toBe(4);
|
const inventory = createInventory([]);
|
||||||
});
|
|
||||||
|
|
||||||
it('should add item buff attack damage', () => {
|
expect(() => payCardCost(player, 'uses', 1, 'missing', inventory)).not.toThrow();
|
||||||
const state = createTestCombatState();
|
|
||||||
state.itemBuffs.push({
|
|
||||||
effectId: 'attackBuff',
|
|
||||||
stacks: 3,
|
|
||||||
timing: 'itemUntilPlayed',
|
|
||||||
sourceItemId: 'item-1',
|
|
||||||
targetItemId: 'item-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getModifiedAttackDamage(state, 'some-card', 5)).toBe(5);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { createGameHost, GameHost } from '@/core/game-host';
|
|
||||||
import { createGameContext, createGameCommandRegistry } from '@/core/game';
|
|
||||||
import type { CombatState, CombatGameContext } from '@/samples/slay-the-spire-like/combat/types';
|
|
||||||
import { createCombatState } from '@/samples/slay-the-spire-like/combat/state';
|
|
||||||
import { runCombat } from '@/samples/slay-the-spire-like/combat/procedure';
|
|
||||||
import { prompts } from '@/samples/slay-the-spire-like/combat/prompts';
|
|
||||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
|
||||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
|
||||||
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
|
|
||||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
|
||||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
|
||||||
import { encounterDesertData, enemyDesertData } from '@/samples/slay-the-spire-like/data';
|
|
||||||
|
|
||||||
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
|
|
||||||
const shape = parseShapeString(shapeStr);
|
|
||||||
return {
|
|
||||||
itemData: {
|
|
||||||
type: 'weapon',
|
|
||||||
name,
|
|
||||||
shape: shapeStr,
|
|
||||||
costType: 'energy',
|
|
||||||
costCount: 1,
|
|
||||||
targetType: 'single',
|
|
||||||
price: 10,
|
|
||||||
desc: '测试',
|
|
||||||
},
|
|
||||||
shape,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestInventory(): GridInventory<GameItemMeta> {
|
|
||||||
const inv = createGridInventory<GameItemMeta>(6, 4);
|
|
||||||
const meta1 = createTestMeta('短刀', 'oe');
|
|
||||||
const item1: InventoryItem<GameItemMeta> = {
|
|
||||||
id: 'item-1',
|
|
||||||
shape: meta1.shape,
|
|
||||||
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
|
||||||
meta: meta1,
|
|
||||||
};
|
|
||||||
placeItem(inv, item1);
|
|
||||||
return inv;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestCombatState(): CombatState {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const playerState: PlayerState = { maxHp: 50, currentHp: 50, gold: 0 };
|
|
||||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
|
||||||
return createCombatState(playerState, inv, encounter);
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForPrompt(host: GameHost<CombatState>): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const check = () => {
|
|
||||||
if (host.activePromptSchema.value !== null) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
setTimeout(check, 10);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
check();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('combat/procedure', () => {
|
|
||||||
describe('runCombat with GameHost', () => {
|
|
||||||
it('should start combat and prompt for player action', async () => {
|
|
||||||
const registry = createGameCommandRegistry<CombatState>();
|
|
||||||
const initialState = createTestCombatState();
|
|
||||||
const host = new GameHost(
|
|
||||||
registry,
|
|
||||||
() => createTestCombatState(),
|
|
||||||
async (ctx) => {
|
|
||||||
return await runCombat(ctx);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const combatPromise = host.start(42);
|
|
||||||
|
|
||||||
await waitForPrompt(host);
|
|
||||||
expect(host.activePromptSchema.value).not.toBeNull();
|
|
||||||
expect(host.activePromptSchema.value?.name).toBe('play-card');
|
|
||||||
|
|
||||||
host._context._commands._cancel();
|
|
||||||
try { await combatPromise; } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept play-card input', async () => {
|
|
||||||
const registry = createGameCommandRegistry<CombatState>();
|
|
||||||
const host = new GameHost(
|
|
||||||
registry,
|
|
||||||
() => createTestCombatState(),
|
|
||||||
async (ctx) => {
|
|
||||||
return await runCombat(ctx);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const combatPromise = host.start(42);
|
|
||||||
await waitForPrompt(host);
|
|
||||||
|
|
||||||
const state = host.state.value;
|
|
||||||
const cardId = state.player.deck.hand[0];
|
|
||||||
|
|
||||||
const error = host.tryAnswerPrompt(prompts.playCard, cardId, state.enemyOrder[0]);
|
|
||||||
expect(error).toBeNull();
|
|
||||||
|
|
||||||
host._context._commands._cancel();
|
|
||||||
try { await combatPromise; } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject invalid card play', async () => {
|
|
||||||
const registry = createGameCommandRegistry<CombatState>();
|
|
||||||
const host = new GameHost(
|
|
||||||
registry,
|
|
||||||
() => createTestCombatState(),
|
|
||||||
async (ctx) => {
|
|
||||||
return await runCombat(ctx);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const combatPromise = host.start(42);
|
|
||||||
await waitForPrompt(host);
|
|
||||||
|
|
||||||
const error = host.tryAnswerPrompt(prompts.playCard, 'nonexistent-card');
|
|
||||||
expect(error).not.toBeNull();
|
|
||||||
|
|
||||||
host._context._commands._cancel();
|
|
||||||
try { await combatPromise; } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should transition to end-turn after playing cards', async () => {
|
|
||||||
const registry = createGameCommandRegistry<CombatState>();
|
|
||||||
const host = new GameHost(
|
|
||||||
registry,
|
|
||||||
() => createTestCombatState(),
|
|
||||||
async (ctx) => {
|
|
||||||
return await runCombat(ctx);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const combatPromise = host.start(42);
|
|
||||||
await waitForPrompt(host);
|
|
||||||
|
|
||||||
const state = host.state.value;
|
|
||||||
const cardId = state.player.deck.hand[0];
|
|
||||||
|
|
||||||
host.tryAnswerPrompt(prompts.playCard, cardId, state.enemyOrder[0]);
|
|
||||||
await waitForPrompt(host);
|
|
||||||
|
|
||||||
expect(host.activePromptSchema.value).not.toBeNull();
|
|
||||||
|
|
||||||
host._context._commands._cancel();
|
|
||||||
try { await combatPromise; } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept end-turn', async () => {
|
|
||||||
const registry = createGameCommandRegistry<CombatState>();
|
|
||||||
const host = new GameHost(
|
|
||||||
registry,
|
|
||||||
() => createTestCombatState(),
|
|
||||||
async (ctx) => {
|
|
||||||
return await runCombat(ctx);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const combatPromise = host.start(42);
|
|
||||||
await waitForPrompt(host);
|
|
||||||
|
|
||||||
const error = host.tryAnswerPrompt(prompts.endTurn);
|
|
||||||
expect(error).toBeNull();
|
|
||||||
|
|
||||||
host._context._commands._cancel();
|
|
||||||
try { await combatPromise; } catch {}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('combat outcome', () => {
|
|
||||||
it('should return victory when all enemies are dead', async () => {
|
|
||||||
const registry = createGameCommandRegistry<CombatState>();
|
|
||||||
const host = new GameHost(
|
|
||||||
registry,
|
|
||||||
() => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
for (const enemyId of state.enemyOrder) {
|
|
||||||
state.enemies[enemyId].hp = 1;
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
},
|
|
||||||
async (ctx) => {
|
|
||||||
return await runCombat(ctx);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const combatPromise = host.start(42);
|
|
||||||
await waitForPrompt(host);
|
|
||||||
|
|
||||||
let iterations = 0;
|
|
||||||
while (host.status.value === 'running' && iterations < 100) {
|
|
||||||
const state = host.state.value;
|
|
||||||
if (host.activePromptSchema.value?.name === 'play-card') {
|
|
||||||
const cardId = state.player.deck.hand[0];
|
|
||||||
if (cardId) {
|
|
||||||
const targetId = state.enemyOrder.find(id => state.enemies[id].isAlive);
|
|
||||||
host.tryAnswerPrompt(prompts.playCard, cardId, targetId);
|
|
||||||
}
|
|
||||||
} else if (host.activePromptSchema.value?.name === 'end-turn') {
|
|
||||||
host.tryAnswerPrompt(prompts.endTurn);
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, 10));
|
|
||||||
iterations++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (host.status.value === 'running') {
|
|
||||||
host._context._commands._cancel();
|
|
||||||
try { await combatPromise; } catch {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('combat state transitions', () => {
|
|
||||||
it('should track turn number across turns', async () => {
|
|
||||||
const registry = createGameCommandRegistry<CombatState>();
|
|
||||||
const host = new GameHost(
|
|
||||||
registry,
|
|
||||||
() => createTestCombatState(),
|
|
||||||
async (ctx) => {
|
|
||||||
return await runCombat(ctx);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const combatPromise = host.start(42);
|
|
||||||
await waitForPrompt(host);
|
|
||||||
|
|
||||||
host.tryAnswerPrompt(prompts.endTurn);
|
|
||||||
await waitForPrompt(host);
|
|
||||||
|
|
||||||
host._context._commands._cancel();
|
|
||||||
try { await combatPromise; } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reset energy at start of player turn', async () => {
|
|
||||||
const registry = createGameCommandRegistry<CombatState>();
|
|
||||||
const host = new GameHost(
|
|
||||||
registry,
|
|
||||||
() => createTestCombatState(),
|
|
||||||
async (ctx) => {
|
|
||||||
return await runCombat(ctx);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const combatPromise = host.start(42);
|
|
||||||
await waitForPrompt(host);
|
|
||||||
|
|
||||||
const state = host.state.value;
|
|
||||||
expect(state.player.energy).toBe(state.player.maxEnergy);
|
|
||||||
|
|
||||||
host._context._commands._cancel();
|
|
||||||
try { await combatPromise; } catch {}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,303 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import {
|
|
||||||
createCombatState,
|
|
||||||
createEnemyInstance,
|
|
||||||
createPlayerCombatState,
|
|
||||||
drawCardsToHand,
|
|
||||||
addFatigueCards,
|
|
||||||
discardHand,
|
|
||||||
discardCard,
|
|
||||||
exhaustCard,
|
|
||||||
getEnemyCurrentIntent,
|
|
||||||
advanceEnemyIntent,
|
|
||||||
getEffectTiming,
|
|
||||||
getEffectData,
|
|
||||||
INITIAL_HAND_SIZE,
|
|
||||||
DEFAULT_MAX_ENERGY,
|
|
||||||
FATIGUE_CARDS_PER_SHUFFLE,
|
|
||||||
} from '@/samples/slay-the-spire-like/combat/state';
|
|
||||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
|
||||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
|
||||||
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
|
|
||||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
|
||||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
|
||||||
import { encounterDesertData, enemyDesertData, effectDesertData } from '@/samples/slay-the-spire-like/data';
|
|
||||||
|
|
||||||
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
|
|
||||||
const shape = parseShapeString(shapeStr);
|
|
||||||
return {
|
|
||||||
itemData: {
|
|
||||||
type: 'weapon',
|
|
||||||
name,
|
|
||||||
shape: shapeStr,
|
|
||||||
costType: 'energy',
|
|
||||||
costCount: 1,
|
|
||||||
targetType: 'single',
|
|
||||||
price: 10,
|
|
||||||
desc: '测试物品',
|
|
||||||
},
|
|
||||||
shape,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestInventory(): GridInventory<GameItemMeta> {
|
|
||||||
const inv = createGridInventory<GameItemMeta>(6, 4);
|
|
||||||
const meta1 = createTestMeta('短刀', 'oe');
|
|
||||||
const item1: InventoryItem<GameItemMeta> = {
|
|
||||||
id: 'item-1',
|
|
||||||
shape: meta1.shape,
|
|
||||||
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
|
||||||
meta: meta1,
|
|
||||||
};
|
|
||||||
placeItem(inv, item1);
|
|
||||||
return inv;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestPlayerState(): PlayerState {
|
|
||||||
return { maxHp: 50, currentHp: 50, gold: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('combat/state', () => {
|
|
||||||
describe('constants', () => {
|
|
||||||
it('should have correct default values', () => {
|
|
||||||
expect(INITIAL_HAND_SIZE).toBe(5);
|
|
||||||
expect(DEFAULT_MAX_ENERGY).toBe(3);
|
|
||||||
expect(FATIGUE_CARDS_PER_SHUFFLE).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createEnemyInstance', () => {
|
|
||||||
it('should create enemy from desert data', () => {
|
|
||||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
|
||||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
|
||||||
|
|
||||||
expect(enemy.templateId).toBe('仙人掌怪');
|
|
||||||
expect(enemy.hp).toBe(cactusData.initHp);
|
|
||||||
expect(enemy.maxHp).toBe(cactusData.initHp);
|
|
||||||
expect(enemy.isAlive).toBe(true);
|
|
||||||
expect(enemy.hadDefendBroken).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply bonus HP', () => {
|
|
||||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
|
||||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 5, { value: 0 });
|
|
||||||
|
|
||||||
expect(enemy.hp).toBe(cactusData.initHp + 5);
|
|
||||||
expect(enemy.maxHp).toBe(cactusData.initHp + 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize buffs from template', () => {
|
|
||||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
|
||||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
|
||||||
|
|
||||||
expect(enemy.buffs['spike']).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set initial intent', () => {
|
|
||||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
|
||||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
|
||||||
|
|
||||||
expect(enemy.currentIntentId).toBe(cactusData.initialIntent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate unique IDs', () => {
|
|
||||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
|
||||||
const e1 = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
|
||||||
const e2 = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
|
||||||
|
|
||||||
expect(e1.id).not.toBe(e2.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createPlayerCombatState', () => {
|
|
||||||
it('should create player state from run state and inventory', () => {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const playerState = createTestPlayerState();
|
|
||||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
||||||
|
|
||||||
expect(combatPlayer.hp).toBe(50);
|
|
||||||
expect(combatPlayer.maxHp).toBe(50);
|
|
||||||
expect(combatPlayer.energy).toBe(DEFAULT_MAX_ENERGY);
|
|
||||||
expect(combatPlayer.maxEnergy).toBe(DEFAULT_MAX_ENERGY);
|
|
||||||
expect(Object.keys(combatPlayer.buffs).length).toBe(0);
|
|
||||||
expect(combatPlayer.damagedThisTurn).toBe(false);
|
|
||||||
expect(combatPlayer.cardsDiscardedThisTurn).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate deck from inventory', () => {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const playerState = createTestPlayerState();
|
|
||||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
||||||
|
|
||||||
expect(Object.keys(combatPlayer.deck.cards).length).toBeGreaterThan(0);
|
|
||||||
expect(combatPlayer.deck.drawPile.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createCombatState', () => {
|
|
||||||
it('should create full combat state from encounter', () => {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const playerState = createTestPlayerState();
|
|
||||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
|
||||||
|
|
||||||
const combat = createCombatState(playerState, inv, encounter);
|
|
||||||
|
|
||||||
expect(combat.phase).toBe('playerTurn');
|
|
||||||
expect(combat.turnNumber).toBe(1);
|
|
||||||
expect(combat.result).toBeNull();
|
|
||||||
expect(combat.loot).toEqual([]);
|
|
||||||
expect(combat.fatigueAddedCount).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create enemies from encounter data', () => {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const playerState = createTestPlayerState();
|
|
||||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
|
||||||
|
|
||||||
const combat = createCombatState(playerState, inv, encounter);
|
|
||||||
|
|
||||||
expect(combat.enemyOrder.length).toBeGreaterThan(0);
|
|
||||||
for (const enemyId of combat.enemyOrder) {
|
|
||||||
expect(combat.enemies[enemyId].isAlive).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should draw initial hand', () => {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const playerState = createTestPlayerState();
|
|
||||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
|
||||||
|
|
||||||
const combat = createCombatState(playerState, inv, encounter);
|
|
||||||
|
|
||||||
expect(combat.player.deck.hand.length).toBe(INITIAL_HAND_SIZE);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('drawCardsToHand', () => {
|
|
||||||
it('should draw cards from draw pile to hand', () => {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const playerState = createTestPlayerState();
|
|
||||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
||||||
|
|
||||||
const initialDrawPile = combatPlayer.deck.drawPile.length;
|
|
||||||
const initialHand = combatPlayer.deck.hand.length;
|
|
||||||
drawCardsToHand(combatPlayer.deck, 3);
|
|
||||||
|
|
||||||
expect(combatPlayer.deck.hand.length).toBe(initialHand + 3);
|
|
||||||
expect(combatPlayer.deck.drawPile.length).toBe(initialDrawPile - 3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('addFatigueCards', () => {
|
|
||||||
it('should add fatigue cards to draw pile', () => {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const playerState = createTestPlayerState();
|
|
||||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
||||||
|
|
||||||
const initialCount = Object.keys(combatPlayer.deck.cards).length;
|
|
||||||
const fatigueCounter = { value: 0 };
|
|
||||||
addFatigueCards(combatPlayer.deck, FATIGUE_CARDS_PER_SHUFFLE, fatigueCounter);
|
|
||||||
|
|
||||||
expect(Object.keys(combatPlayer.deck.cards).length).toBe(initialCount + FATIGUE_CARDS_PER_SHUFFLE);
|
|
||||||
expect(fatigueCounter.value).toBe(FATIGUE_CARDS_PER_SHUFFLE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create fatigue cards with correct properties', () => {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const playerState = createTestPlayerState();
|
|
||||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
||||||
|
|
||||||
addFatigueCards(combatPlayer.deck, 1, { value: 0 });
|
|
||||||
const fatigueCard = combatPlayer.deck.cards['fatigue-1'];
|
|
||||||
|
|
||||||
expect(fatigueCard).toBeDefined();
|
|
||||||
expect(fatigueCard.displayName).toBe('疲劳');
|
|
||||||
expect(fatigueCard.sourceItemId).toBeNull();
|
|
||||||
expect(fatigueCard.itemData).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('discardHand', () => {
|
|
||||||
it('should move all hand cards to discard pile', () => {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const playerState = createTestPlayerState();
|
|
||||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
||||||
drawCardsToHand(combatPlayer.deck, 3);
|
|
||||||
|
|
||||||
const handCount = combatPlayer.deck.hand.length;
|
|
||||||
discardHand(combatPlayer.deck);
|
|
||||||
|
|
||||||
expect(combatPlayer.deck.hand).toEqual([]);
|
|
||||||
expect(combatPlayer.deck.discardPile.length).toBe(handCount);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('discardCard / exhaustCard', () => {
|
|
||||||
it('should move a card from hand to discard pile', () => {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const playerState = createTestPlayerState();
|
|
||||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
||||||
drawCardsToHand(combatPlayer.deck, 3);
|
|
||||||
|
|
||||||
const cardId = combatPlayer.deck.hand[0];
|
|
||||||
discardCard(combatPlayer.deck, cardId);
|
|
||||||
|
|
||||||
expect(combatPlayer.deck.hand.includes(cardId)).toBe(false);
|
|
||||||
expect(combatPlayer.deck.discardPile.includes(cardId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should move a card from hand to exhaust pile', () => {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const playerState = createTestPlayerState();
|
|
||||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
|
||||||
drawCardsToHand(combatPlayer.deck, 3);
|
|
||||||
|
|
||||||
const cardId = combatPlayer.deck.hand[0];
|
|
||||||
exhaustCard(combatPlayer.deck, cardId);
|
|
||||||
|
|
||||||
expect(combatPlayer.deck.hand.includes(cardId)).toBe(false);
|
|
||||||
expect(combatPlayer.deck.exhaustPile.includes(cardId)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('enemy intent', () => {
|
|
||||||
it('should get current intent', () => {
|
|
||||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
|
||||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
|
||||||
|
|
||||||
const intent = getEnemyCurrentIntent(enemy);
|
|
||||||
expect(intent).toBeDefined();
|
|
||||||
expect(intent!.id).toBe('boost');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should advance intent after action', () => {
|
|
||||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
|
||||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
|
||||||
|
|
||||||
const originalIntent = enemy.currentIntentId;
|
|
||||||
advanceEnemyIntent(enemy);
|
|
||||||
|
|
||||||
expect(enemy.currentIntentId).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getEffectTiming / getEffectData', () => {
|
|
||||||
it('should return timing for known effects', () => {
|
|
||||||
expect(getEffectTiming('attack')).toBe('instant');
|
|
||||||
expect(getEffectTiming('defend')).toBe('posture');
|
|
||||||
expect(getEffectTiming('spike')).toBe('permanent');
|
|
||||||
expect(getEffectTiming('energyDrain')).toBe('lingering');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return undefined for unknown effects', () => {
|
|
||||||
expect(getEffectTiming('nonexistent')).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return effect data for known effects', () => {
|
|
||||||
const data = getEffectData('attack');
|
|
||||||
expect(data).toBeDefined();
|
|
||||||
expect(data!.id).toBe('attack');
|
|
||||||
expect(data!.name).toBe('攻击');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,254 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import {
|
|
||||||
createCombatTriggerRegistry,
|
|
||||||
dispatchTrigger,
|
|
||||||
dispatchAttackedTrigger,
|
|
||||||
dispatchDamageTrigger,
|
|
||||||
dispatchOutgoingDamageTrigger,
|
|
||||||
dispatchIncomingDamageTrigger,
|
|
||||||
} from '@/samples/slay-the-spire-like/combat/triggers';
|
|
||||||
import type { TriggerContext, CombatTriggerRegistry } from '@/samples/slay-the-spire-like/combat/triggers';
|
|
||||||
import type { CombatState } from '@/samples/slay-the-spire-like/combat/types';
|
|
||||||
import { createCombatState } from '@/samples/slay-the-spire-like/combat/state';
|
|
||||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
|
||||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
|
||||||
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
|
|
||||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
|
||||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
|
||||||
import { encounterDesertData, enemyDesertData } from '@/samples/slay-the-spire-like/data';
|
|
||||||
import { Mulberry32RNG } from '@/utils/rng';
|
|
||||||
|
|
||||||
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
|
|
||||||
const shape = parseShapeString(shapeStr);
|
|
||||||
return {
|
|
||||||
itemData: {
|
|
||||||
type: 'weapon',
|
|
||||||
name,
|
|
||||||
shape: shapeStr,
|
|
||||||
costType: 'energy',
|
|
||||||
costCount: 1,
|
|
||||||
targetType: 'single',
|
|
||||||
price: 10,
|
|
||||||
desc: '测试',
|
|
||||||
},
|
|
||||||
shape,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestInventory(): GridInventory<GameItemMeta> {
|
|
||||||
const inv = createGridInventory<GameItemMeta>(6, 4);
|
|
||||||
const meta1 = createTestMeta('短刀', 'oe');
|
|
||||||
const item1: InventoryItem<GameItemMeta> = {
|
|
||||||
id: 'item-1',
|
|
||||||
shape: meta1.shape,
|
|
||||||
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
|
||||||
meta: meta1,
|
|
||||||
};
|
|
||||||
placeItem(inv, item1);
|
|
||||||
return inv;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestCombatState(): CombatState {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const playerState: PlayerState = { maxHp: 50, currentHp: 50, gold: 0 };
|
|
||||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
|
||||||
return createCombatState(playerState, inv, encounter);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestTriggerCtx(state: CombatState): TriggerContext {
|
|
||||||
return { state, rng: new Mulberry32RNG(42) };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('combat/triggers', () => {
|
|
||||||
describe('createCombatTriggerRegistry', () => {
|
|
||||||
it('should create registry with desert zone triggers', () => {
|
|
||||||
const registry = createCombatTriggerRegistry();
|
|
||||||
|
|
||||||
expect(registry['spike']).toBeDefined();
|
|
||||||
expect(registry['aim']).toBeDefined();
|
|
||||||
expect(registry['charge']).toBeDefined();
|
|
||||||
expect(registry['roll']).toBeDefined();
|
|
||||||
expect(registry['tailSting']).toBeDefined();
|
|
||||||
expect(registry['energyDrain']).toBeDefined();
|
|
||||||
expect(registry['molt']).toBeDefined();
|
|
||||||
expect(registry['storm']).toBeDefined();
|
|
||||||
expect(registry['vultureEye']).toBeDefined();
|
|
||||||
expect(registry['venom']).toBeDefined();
|
|
||||||
expect(registry['static']).toBeDefined();
|
|
||||||
expect(registry['curse']).toBeDefined();
|
|
||||||
expect(registry['discard']).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('spike trigger', () => {
|
|
||||||
it('should deal damage to attacker when enemy is attacked', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const registry = createCombatTriggerRegistry();
|
|
||||||
const ctx = createTestTriggerCtx(state);
|
|
||||||
const enemyId = state.enemyOrder[0];
|
|
||||||
state.enemies[enemyId].buffs['spike'] = 2;
|
|
||||||
|
|
||||||
const initialPlayerHp = state.player.hp;
|
|
||||||
dispatchAttackedTrigger(ctx, 'player', enemyId, 5, registry);
|
|
||||||
|
|
||||||
expect(state.player.hp).toBeLessThan(initialPlayerHp);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('aim trigger', () => {
|
|
||||||
it('should double outgoing damage with aim stacks', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const registry = createCombatTriggerRegistry();
|
|
||||||
const ctx = createTestTriggerCtx(state);
|
|
||||||
const enemyId = state.enemyOrder[0];
|
|
||||||
state.enemies[enemyId].buffs['aim'] = 3;
|
|
||||||
|
|
||||||
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
|
|
||||||
expect(modified).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not double damage with 0 aim stacks', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const registry = createCombatTriggerRegistry();
|
|
||||||
const ctx = createTestTriggerCtx(state);
|
|
||||||
const enemyId = state.enemyOrder[0];
|
|
||||||
state.enemies[enemyId].buffs['aim'] = 0;
|
|
||||||
|
|
||||||
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
|
|
||||||
expect(modified).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should lose aim stacks on damage', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const registry = createCombatTriggerRegistry();
|
|
||||||
const ctx = createTestTriggerCtx(state);
|
|
||||||
const enemyId = state.enemyOrder[0];
|
|
||||||
state.enemies[enemyId].buffs['aim'] = 5;
|
|
||||||
|
|
||||||
dispatchDamageTrigger(ctx, enemyId, 3, registry);
|
|
||||||
|
|
||||||
expect(state.enemies[enemyId].buffs['aim']).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('charge trigger', () => {
|
|
||||||
it('should double outgoing and incoming damage', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const registry = createCombatTriggerRegistry();
|
|
||||||
const ctx = createTestTriggerCtx(state);
|
|
||||||
const enemyId = state.enemyOrder[0];
|
|
||||||
state.enemies[enemyId].buffs['charge'] = 2;
|
|
||||||
|
|
||||||
const outDmg = dispatchOutgoingDamageTrigger(ctx, enemyId, 6, registry);
|
|
||||||
expect(outDmg).toBe(12);
|
|
||||||
|
|
||||||
const inDmg = dispatchIncomingDamageTrigger(ctx, enemyId, 6, registry);
|
|
||||||
expect(inDmg).toBe(12);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should lose charge stacks on damage', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const registry = createCombatTriggerRegistry();
|
|
||||||
const ctx = createTestTriggerCtx(state);
|
|
||||||
const enemyId = state.enemyOrder[0];
|
|
||||||
state.enemies[enemyId].buffs['charge'] = 5;
|
|
||||||
|
|
||||||
dispatchDamageTrigger(ctx, enemyId, 3, registry);
|
|
||||||
|
|
||||||
expect(state.enemies[enemyId].buffs['charge']).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('roll trigger', () => {
|
|
||||||
it('should increase damage when roll >= 10', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const registry = createCombatTriggerRegistry();
|
|
||||||
const ctx = createTestTriggerCtx(state);
|
|
||||||
const enemyId = state.enemyOrder[0];
|
|
||||||
state.enemies[enemyId].buffs['roll'] = 20;
|
|
||||||
|
|
||||||
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
|
|
||||||
expect(modified).toBe(7);
|
|
||||||
expect(state.enemies[enemyId].buffs['roll']).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not modify damage when roll < 10', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const registry = createCombatTriggerRegistry();
|
|
||||||
const ctx = createTestTriggerCtx(state);
|
|
||||||
const enemyId = state.enemyOrder[0];
|
|
||||||
state.enemies[enemyId].buffs['roll'] = 5;
|
|
||||||
|
|
||||||
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
|
|
||||||
expect(modified).toBe(5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('tailSting trigger', () => {
|
|
||||||
it('should deal damage to player at turn end', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const registry = createCombatTriggerRegistry();
|
|
||||||
const ctx = createTestTriggerCtx(state);
|
|
||||||
const enemyId = state.enemyOrder[0];
|
|
||||||
state.enemies[enemyId].buffs['tailSting'] = 3;
|
|
||||||
|
|
||||||
const initialHp = state.player.hp;
|
|
||||||
dispatchTrigger(ctx, 'onTurnEnd', enemyId, registry);
|
|
||||||
|
|
||||||
expect(state.player.hp).toBeLessThan(initialHp);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('static trigger', () => {
|
|
||||||
it('should increase incoming damage to player', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const registry = createCombatTriggerRegistry();
|
|
||||||
const ctx = createTestTriggerCtx(state);
|
|
||||||
state.player.buffs['static'] = 2;
|
|
||||||
|
|
||||||
const modified = dispatchIncomingDamageTrigger(ctx, 'player', 5, registry);
|
|
||||||
expect(modified).toBe(7);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('molt trigger', () => {
|
|
||||||
it('should cause enemy to flee when molt stacks >= maxHp', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const registry = createCombatTriggerRegistry();
|
|
||||||
const ctx = createTestTriggerCtx(state);
|
|
||||||
const enemyId = state.enemyOrder[0];
|
|
||||||
state.enemies[enemyId].buffs['molt'] = state.enemies[enemyId].maxHp;
|
|
||||||
|
|
||||||
dispatchDamageTrigger(ctx, enemyId, 1, registry);
|
|
||||||
|
|
||||||
expect(state.enemies[enemyId].isAlive).toBe(false);
|
|
||||||
expect(state.result).toBe('fled');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not cause flee when molt stacks < maxHp', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const registry = createCombatTriggerRegistry();
|
|
||||||
const ctx = createTestTriggerCtx(state);
|
|
||||||
const enemyId = state.enemyOrder[0];
|
|
||||||
state.enemies[enemyId].buffs['molt'] = 1;
|
|
||||||
|
|
||||||
dispatchDamageTrigger(ctx, enemyId, 1, registry);
|
|
||||||
|
|
||||||
expect(state.enemies[enemyId].isAlive).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('dispatchTrigger with missing handler', () => {
|
|
||||||
it('should be a no-op for unknown buff', () => {
|
|
||||||
const state = createTestCombatState();
|
|
||||||
const registry = createCombatTriggerRegistry();
|
|
||||||
const ctx = createTestTriggerCtx(state);
|
|
||||||
const enemyId = state.enemyOrder[0];
|
|
||||||
state.enemies[enemyId].buffs['nonexistentBuff'] = 5;
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
dispatchTrigger(ctx, 'onTurnStart', enemyId, registry);
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,311 +1,8 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import data from '@/samples/slay-the-spire-like/data';
|
||||||
heroItemFighter1Data,
|
|
||||||
encounterDesertData,
|
|
||||||
enemyDesertData,
|
|
||||||
enemyIntentDesertData,
|
|
||||||
effectDesertData,
|
|
||||||
statusCardDesertData,
|
|
||||||
} from '@/samples/slay-the-spire-like/data';
|
|
||||||
|
|
||||||
describe('heroItemFighter1.csv import', () => {
|
describe('data import', () => {
|
||||||
it('should import data as an array', () => {
|
it('should import properly', () => {
|
||||||
expect(Array.isArray(heroItemFighter1Data)).toBe(true);
|
expect(data.desert.effects).toBeDefined();
|
||||||
expect(heroItemFighter1Data.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have expected number of items', () => {
|
|
||||||
expect(heroItemFighter1Data.length).toBe(24);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct fields for each item', () => {
|
|
||||||
for (const item of heroItemFighter1Data) {
|
|
||||||
expect(item).toHaveProperty('type');
|
|
||||||
expect(item).toHaveProperty('name');
|
|
||||||
expect(item).toHaveProperty('shape');
|
|
||||||
expect(item).toHaveProperty('costType');
|
|
||||||
expect(item).toHaveProperty('costCount');
|
|
||||||
expect(item).toHaveProperty('targetType');
|
|
||||||
expect(item).toHaveProperty('desc');
|
|
||||||
expect(item).toHaveProperty('effects');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse costCount as number', () => {
|
|
||||||
for (const item of heroItemFighter1Data) {
|
|
||||||
expect(typeof item.costCount).toBe('number');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should contain expected items by name', () => {
|
|
||||||
const names = heroItemFighter1Data.map(item => item.name);
|
|
||||||
expect(names).toContain('剑');
|
|
||||||
expect(names).toContain('盾');
|
|
||||||
expect(names).toContain('绷带');
|
|
||||||
expect(names).toContain('火把');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have valid type values', () => {
|
|
||||||
const validTypes = ['weapon', 'armor', 'consumable', 'tool'];
|
|
||||||
for (const item of heroItemFighter1Data) {
|
|
||||||
expect(validTypes).toContain(item.type);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have valid costType values', () => {
|
|
||||||
const validCostTypes = ['energy', 'uses'];
|
|
||||||
for (const item of heroItemFighter1Data) {
|
|
||||||
expect(validCostTypes).toContain(item.costType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have valid targetType values', () => {
|
|
||||||
const validTargetTypes = ['single', 'none'];
|
|
||||||
for (const item of heroItemFighter1Data) {
|
|
||||||
expect(validTargetTypes).toContain(item.targetType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct item counts by type', () => {
|
|
||||||
const typeCounts = heroItemFighter1Data.reduce((acc, item) => {
|
|
||||||
acc[item.type] = (acc[item.type] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, number>);
|
|
||||||
|
|
||||||
expect(typeCounts['weapon']).toBe(6);
|
|
||||||
expect(typeCounts['armor']).toBe(6);
|
|
||||||
expect(typeCounts['consumable']).toBe(6);
|
|
||||||
expect(typeCounts['tool']).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have effects with target, effect ref, and value', () => {
|
|
||||||
for (const item of heroItemFighter1Data) {
|
|
||||||
expect(Array.isArray(item.effects)).toBe(true);
|
|
||||||
for (const [target, effect, value] of item.effects) {
|
|
||||||
expect(target === 'self' || target === 'target' || target === 'all' || target === 'random').toBe(true);
|
|
||||||
expect(typeof effect === 'string' || typeof effect === 'object').toBe(true);
|
|
||||||
expect(typeof value).toBe('number');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('encounterDesert.csv import', () => {
|
|
||||||
it('should import data as an array', () => {
|
|
||||||
expect(Array.isArray(encounterDesertData)).toBe(true);
|
|
||||||
expect(encounterDesertData.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct encounter type counts', () => {
|
|
||||||
const typeCounts = encounterDesertData.reduce((acc, e) => {
|
|
||||||
acc[e.type] = (acc[e.type] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, number>);
|
|
||||||
|
|
||||||
expect(typeCounts['minion']).toBe(10);
|
|
||||||
expect(typeCounts['elite']).toBe(4);
|
|
||||||
expect(typeCounts['shop']).toBe(2);
|
|
||||||
expect(typeCounts['camp']).toBe(2);
|
|
||||||
expect(typeCounts['curio']).toBe(8);
|
|
||||||
expect(typeCounts['event']).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have enemies for combat encounters', () => {
|
|
||||||
for (const e of encounterDesertData) {
|
|
||||||
if (e.type === 'minion' || e.type === 'elite') {
|
|
||||||
expect(Array.isArray(e.enemies)).toBe(true);
|
|
||||||
expect(e.enemies.length).toBeGreaterThan(0);
|
|
||||||
for (const [enemy, bonusHp] of e.enemies) {
|
|
||||||
expect(typeof enemy === 'string' || typeof enemy === 'object').toBe(true);
|
|
||||||
expect(typeof bonusHp).toBe('number');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have empty enemies for non-combat encounters', () => {
|
|
||||||
for (const e of encounterDesertData) {
|
|
||||||
if (e.type === 'shop' || e.type === 'camp') {
|
|
||||||
expect(e.enemies.length).toBe(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have dialogue for curio and event encounters', () => {
|
|
||||||
for (const e of encounterDesertData) {
|
|
||||||
if (e.type === 'curio' || e.type === 'event') {
|
|
||||||
expect(e.dialogue).toBeTruthy();
|
|
||||||
expect(e.dialogue.startsWith('desert_')).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('effectDesert.csv import', () => {
|
|
||||||
it('should import data as an array', () => {
|
|
||||||
expect(Array.isArray(effectDesertData)).toBe(true);
|
|
||||||
expect(effectDesertData.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have expected number of effects', () => {
|
|
||||||
expect(effectDesertData.length).toBe(35);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct fields for each effect', () => {
|
|
||||||
for (const effect of effectDesertData) {
|
|
||||||
expect(effect).toHaveProperty('id');
|
|
||||||
expect(effect).toHaveProperty('name');
|
|
||||||
expect(effect).toHaveProperty('description');
|
|
||||||
expect(effect).toHaveProperty('timing');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have valid timing values', () => {
|
|
||||||
const validTimings = ['instant', 'temporary', 'lingering', 'permanent', 'posture', 'card', 'cardDraw', 'cardHand', 'item', 'itemUntilPlayed'];
|
|
||||||
for (const effect of effectDesertData) {
|
|
||||||
expect(validTimings).toContain(effect.timing);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should contain core effect types', () => {
|
|
||||||
const ids = effectDesertData.map(e => e.id);
|
|
||||||
expect(ids).toContain('attack');
|
|
||||||
expect(ids).toContain('defend');
|
|
||||||
expect(ids).toContain('spike');
|
|
||||||
expect(ids).toContain('venom');
|
|
||||||
expect(ids).toContain('draw');
|
|
||||||
expect(ids).toContain('removeWound');
|
|
||||||
expect(ids).toContain('gainEnergy');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('enemyDesert.csv import', () => {
|
|
||||||
it('should import data as an array', () => {
|
|
||||||
expect(Array.isArray(enemyDesertData)).toBe(true);
|
|
||||||
expect(enemyDesertData.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have expected number of enemies', () => {
|
|
||||||
expect(enemyDesertData.length).toBe(14);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct fields for each enemy', () => {
|
|
||||||
for (const enemy of enemyDesertData) {
|
|
||||||
expect(enemy).toHaveProperty('id');
|
|
||||||
expect(enemy).toHaveProperty('initHp');
|
|
||||||
expect(enemy).toHaveProperty('initBuffs');
|
|
||||||
expect(enemy).toHaveProperty('initialIntent');
|
|
||||||
expect(typeof enemy.initHp).toBe('number');
|
|
||||||
expect(typeof enemy.initialIntent).toBe('string');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have valid HP ranges', () => {
|
|
||||||
for (const enemy of enemyDesertData) {
|
|
||||||
expect(enemy.initHp).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have minions with lower HP than elites', () => {
|
|
||||||
const minionIds = ['仙人掌怪', '蛇', '木乃伊', '枪手', '风卷草', '秃鹫', '沙蝎', '幼沙虫', '蜥蜴', '沙匪'];
|
|
||||||
const eliteIds = ['风暴之灵', '骑马枪手', '沙虫王', '沙漠守卫'];
|
|
||||||
const byId = Object.fromEntries(enemyDesertData.map(e => [e.id, e]));
|
|
||||||
|
|
||||||
for (const id of minionIds) {
|
|
||||||
expect(byId[id].initHp).toBeLessThan(40);
|
|
||||||
}
|
|
||||||
for (const id of eliteIds) {
|
|
||||||
expect(byId[id].initHp).toBeGreaterThanOrEqual(40);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('enemyIntentDesert.csv import', () => {
|
|
||||||
it('should import data as an array', () => {
|
|
||||||
expect(Array.isArray(enemyIntentDesertData)).toBe(true);
|
|
||||||
expect(enemyIntentDesertData.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have expected number of intent rows', () => {
|
|
||||||
expect(enemyIntentDesertData.length).toBe(41);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct fields for each intent', () => {
|
|
||||||
for (const intent of enemyIntentDesertData) {
|
|
||||||
expect(intent).toHaveProperty('enemy');
|
|
||||||
expect(intent).toHaveProperty('id');
|
|
||||||
expect(intent).toHaveProperty('nextIntents');
|
|
||||||
expect(intent).toHaveProperty('brokenIntent');
|
|
||||||
expect(intent).toHaveProperty('effects');
|
|
||||||
expect(intent.enemy).toHaveProperty('id');
|
|
||||||
expect(Array.isArray(intent.nextIntents)).toBe(true);
|
|
||||||
expect(Array.isArray(intent.brokenIntent)).toBe(true);
|
|
||||||
expect(Array.isArray(intent.effects)).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have effects with target, effect ref, and value', () => {
|
|
||||||
for (const intent of enemyIntentDesertData) {
|
|
||||||
for (const [target, effect, value] of intent.effects) {
|
|
||||||
expect(target === 'self' || target === 'player' || target === 'team').toBe(true);
|
|
||||||
expect(typeof effect === 'string' || typeof effect === 'object').toBe(true);
|
|
||||||
expect(typeof value).toBe('number');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should cover all 14 enemies', () => {
|
|
||||||
const enemyIds = new Set(enemyIntentDesertData.map(i => typeof i.enemy === 'string' ? i.enemy : i.enemy.id));
|
|
||||||
expect(enemyIds.size).toBe(14);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('statusCardDesert.csv import', () => {
|
|
||||||
it('should import data as an array', () => {
|
|
||||||
expect(Array.isArray(statusCardDesertData)).toBe(true);
|
|
||||||
expect(statusCardDesertData.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have expected number of status cards', () => {
|
|
||||||
expect(statusCardDesertData.length).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct fields for each status card', () => {
|
|
||||||
for (const card of statusCardDesertData) {
|
|
||||||
expect(card).toHaveProperty('id');
|
|
||||||
expect(card).toHaveProperty('name');
|
|
||||||
expect(card).toHaveProperty('desc');
|
|
||||||
expect(card).toHaveProperty('unplayable');
|
|
||||||
expect(card).toHaveProperty('effects');
|
|
||||||
expect(typeof card.id).toBe('string');
|
|
||||||
expect(typeof card.name).toBe('string');
|
|
||||||
expect(typeof card.desc).toBe('string');
|
|
||||||
expect(typeof card.unplayable).toBe('boolean');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have all cards unplayable', () => {
|
|
||||||
for (const card of statusCardDesertData) {
|
|
||||||
expect(card.unplayable).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have effects with target, effect ref, and value', () => {
|
|
||||||
for (const card of statusCardDesertData) {
|
|
||||||
expect(Array.isArray(card.effects)).toBe(true);
|
|
||||||
for (const [target, effect, value] of card.effects) {
|
|
||||||
expect(target).toBe('self');
|
|
||||||
expect(typeof effect === 'string' || typeof effect === 'object').toBe(true);
|
|
||||||
expect(typeof value).toBe('number');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should contain expected status cards by id', () => {
|
|
||||||
const ids = statusCardDesertData.map(c => c.id);
|
|
||||||
expect(ids).toContain('wound');
|
|
||||||
expect(ids).toContain('venom');
|
|
||||||
expect(ids).toContain('curse');
|
|
||||||
expect(ids).toContain('static');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,57 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
generateDeckFromInventory,
|
generateDeckFromInventory,
|
||||||
createStatusCard,
|
createCard,
|
||||||
createDeckRegions,
|
createDeckRegions,
|
||||||
createPlayerDeck,
|
createPlayerDeck,
|
||||||
generateCardId,
|
generateCardId,
|
||||||
} from '@/samples/slay-the-spire-like/deck/factory';
|
} from '@/samples/slay-the-spire-like/system/deck/factory';
|
||||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/system/grid-inventory';
|
||||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory';
|
||||||
import type { GameItemMeta } from '@/samples/slay-the-spire-like/progress/types';
|
import type { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types';
|
||||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/system/utils/shape-collision';
|
||||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
import { parseShapeString } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
|
||||||
|
import type { CardData, ItemData } from '@/samples/slay-the-spire-like/system/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: create a minimal CardData for testing.
|
||||||
|
*/
|
||||||
|
function createTestCardData(id: string, name: string, desc: string): CardData {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
desc,
|
||||||
|
type: 'item',
|
||||||
|
costType: 'energy',
|
||||||
|
costCount: 1,
|
||||||
|
targetType: 'single',
|
||||||
|
effects: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: create a minimal ItemData for testing.
|
||||||
|
*/
|
||||||
|
function createTestItemData(id: string, name: string, shapeStr: string, desc: string): ItemData {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'weapon',
|
||||||
|
name,
|
||||||
|
shape: shapeStr,
|
||||||
|
card: createTestCardData(id, name, desc),
|
||||||
|
price: 10,
|
||||||
|
description: desc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: create a minimal GameItemMeta for testing.
|
* Helper: create a minimal GameItemMeta for testing.
|
||||||
*/
|
*/
|
||||||
function createTestMeta(name: string, desc: string, shapeStr: string): GameItemMeta {
|
function createTestMeta(name: string, desc: string, shapeStr: string): GameItemMeta {
|
||||||
const shape = parseShapeString(shapeStr);
|
const shape = parseShapeString(shapeStr);
|
||||||
|
const itemData = createTestItemData(name.toLowerCase(), name, shapeStr, desc);
|
||||||
return {
|
return {
|
||||||
itemData: {
|
itemData,
|
||||||
type: 'weapon',
|
|
||||||
name,
|
|
||||||
shape: shapeStr,
|
|
||||||
costType: 'energy',
|
|
||||||
costCount: 1,
|
|
||||||
targetType: 'single',
|
|
||||||
price: 10,
|
|
||||||
desc,
|
|
||||||
},
|
|
||||||
shape,
|
shape,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -70,21 +94,14 @@ describe('deck/factory', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createStatusCard', () => {
|
describe('createCard', () => {
|
||||||
it('should create a card with null sourceItemId and itemData', () => {
|
it('should create a card with itemId and cardData', () => {
|
||||||
const card = createStatusCard('wound-1', '伤口', '无法被弃牌');
|
const cardData = createTestCardData('wound', '伤口', '无法被弃牌');
|
||||||
|
const card = createCard('wound-1', cardData, 0);
|
||||||
expect(card.id).toBe('wound-1');
|
|
||||||
expect(card.sourceItemId).toBeNull();
|
|
||||||
expect(card.itemData).toBeNull();
|
|
||||||
expect(card.cellKey).toBeNull();
|
|
||||||
expect(card.displayName).toBe('伤口');
|
|
||||||
expect(card.description).toBe('无法被弃牌');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have empty region and position', () => {
|
|
||||||
const card = createStatusCard('stun-1', '眩晕', '跳过出牌阶段');
|
|
||||||
|
|
||||||
|
expect(card.id).toBe('card-wound-1-0');
|
||||||
|
expect(card.itemId).toBe('wound-1');
|
||||||
|
expect(card.cardData).toBe(cardData);
|
||||||
expect(card.regionId).toBe('');
|
expect(card.regionId).toBe('');
|
||||||
expect(card.position).toEqual([]);
|
expect(card.position).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
@ -98,10 +115,10 @@ describe('deck/factory', () => {
|
||||||
const deck = generateDeckFromInventory(inv);
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
expect(Object.keys(deck.cards).length).toBe(6);
|
expect(Object.keys(deck.cards).length).toBe(6);
|
||||||
expect(deck.drawPile.length).toBe(6);
|
expect(deck.regions.drawPile.childIds.length).toBe(6);
|
||||||
expect(deck.hand).toEqual([]);
|
expect(deck.regions.hand.childIds).toEqual([]);
|
||||||
expect(deck.discardPile).toEqual([]);
|
expect(deck.regions.discardPile.childIds).toEqual([]);
|
||||||
expect(deck.exhaustPile).toEqual([]);
|
expect(deck.regions.exhaustPile.childIds).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should link cards to their source items', () => {
|
it('should link cards to their source items', () => {
|
||||||
|
|
@ -109,18 +126,18 @@ describe('deck/factory', () => {
|
||||||
const deck = generateDeckFromInventory(inv);
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
const daggerCards = Object.values(deck.cards).filter(
|
const daggerCards = Object.values(deck.cards).filter(
|
||||||
c => c.sourceItemId === 'dagger-1'
|
c => c.itemId === 'dagger-1'
|
||||||
);
|
);
|
||||||
const shieldCards = Object.values(deck.cards).filter(
|
const shieldCards = Object.values(deck.cards).filter(
|
||||||
c => c.sourceItemId === 'shield-1'
|
c => c.itemId === 'shield-1'
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(daggerCards.length).toBe(2);
|
expect(daggerCards.length).toBe(2);
|
||||||
expect(shieldCards.length).toBe(4);
|
expect(shieldCards.length).toBe(4);
|
||||||
|
|
||||||
// Verify item data
|
// Verify card data
|
||||||
expect(daggerCards[0].itemData?.name).toBe('短刀');
|
expect(daggerCards[0].cardData.name).toBe('短刀');
|
||||||
expect(shieldCards[0].itemData?.name).toBe('盾');
|
expect(shieldCards[0].cardData.name).toBe('盾');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set displayName and description from item data', () => {
|
it('should set displayName and description from item data', () => {
|
||||||
|
|
@ -128,28 +145,28 @@ describe('deck/factory', () => {
|
||||||
const deck = generateDeckFromInventory(inv);
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
for (const card of Object.values(deck.cards)) {
|
for (const card of Object.values(deck.cards)) {
|
||||||
expect(card.displayName).toBeTruthy();
|
expect(card.cardData.name).toBeTruthy();
|
||||||
expect(card.description).toBeTruthy();
|
expect(card.cardData.desc).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
const daggerCard = Object.values(deck.cards).find(
|
const daggerCard = Object.values(deck.cards).find(
|
||||||
c => c.itemData?.name === '短刀'
|
c => c.cardData.name === '短刀'
|
||||||
);
|
);
|
||||||
expect(daggerCard?.displayName).toBe('短刀');
|
expect(daggerCard?.cardData.name).toBe('短刀');
|
||||||
expect(daggerCard?.description).toBe('【攻击3】【攻击3】');
|
expect(daggerCard?.cardData.desc).toBe('【攻击3】【攻击3】');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should assign unique cell keys to each card from same item', () => {
|
it('should assign unique IDs to each card from same item', () => {
|
||||||
const inv = createTestInventory();
|
const inv = createTestInventory();
|
||||||
const deck = generateDeckFromInventory(inv);
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
const daggerCards = Object.values(deck.cards).filter(
|
const daggerCards = Object.values(deck.cards).filter(
|
||||||
c => c.sourceItemId === 'dagger-1'
|
c => c.itemId === 'dagger-1'
|
||||||
);
|
);
|
||||||
|
|
||||||
const cellKeys = daggerCards.map(c => c.cellKey);
|
const ids = daggerCards.map(c => c.id);
|
||||||
const uniqueKeys = new Set(cellKeys);
|
const uniqueIds = new Set(ids);
|
||||||
expect(uniqueKeys.size).toBe(cellKeys.length);
|
expect(uniqueIds.size).toBe(ids.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty inventory', () => {
|
it('should handle empty inventory', () => {
|
||||||
|
|
@ -157,19 +174,19 @@ describe('deck/factory', () => {
|
||||||
const deck = generateDeckFromInventory(inv);
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
expect(Object.keys(deck.cards).length).toBe(0);
|
expect(Object.keys(deck.cards).length).toBe(0);
|
||||||
expect(deck.drawPile).toEqual([]);
|
expect(deck.regions.drawPile.childIds).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should place all cards in draw pile initially', () => {
|
it('should place all cards in draw pile initially', () => {
|
||||||
const inv = createTestInventory();
|
const inv = createTestInventory();
|
||||||
const deck = generateDeckFromInventory(inv);
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
for (const cardId of deck.drawPile) {
|
for (const cardId of deck.regions.drawPile.childIds) {
|
||||||
expect(deck.cards[cardId]).toBeDefined();
|
expect(deck.cards[cardId]).toBeDefined();
|
||||||
}
|
}
|
||||||
|
|
||||||
// All cards are in draw pile
|
// All cards are in draw pile
|
||||||
expect(new Set(deck.drawPile).size).toBe(Object.keys(deck.cards).length);
|
expect(new Set(deck.regions.drawPile.childIds).size).toBe(Object.keys(deck.cards).length);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -198,10 +215,10 @@ describe('deck/factory', () => {
|
||||||
const deck = createPlayerDeck();
|
const deck = createPlayerDeck();
|
||||||
|
|
||||||
expect(deck.cards).toEqual({});
|
expect(deck.cards).toEqual({});
|
||||||
expect(deck.drawPile).toEqual([]);
|
expect(deck.regions.drawPile.childIds).toEqual([]);
|
||||||
expect(deck.hand).toEqual([]);
|
expect(deck.regions.hand.childIds).toEqual([]);
|
||||||
expect(deck.discardPile).toEqual([]);
|
expect(deck.regions.discardPile.childIds).toEqual([]);
|
||||||
expect(deck.exhaustPile).toEqual([]);
|
expect(deck.regions.exhaustPile.childIds).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import encounters from '@/samples/slay-the-spire-like/dialogue/encounters/encounters.yarnproject';
|
|
||||||
|
|
||||||
describe('encounters.yarnproject import', () => {
|
|
||||||
it('should load the yarnproject with expected project metadata', () => {
|
|
||||||
expect(encounters.project.projectName).toBe('encounters');
|
|
||||||
expect(encounters.project.baseLanguage).toBe('en');
|
|
||||||
expect(encounters.project.authorName).toContain('hyper');
|
|
||||||
expect(encounters.project.projectFileVersion).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have sourceFiles configured', () => {
|
|
||||||
expect(encounters.project.sourceFiles).toContain('**/*.yarn');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have a valid baseDir', () => {
|
|
||||||
expect(typeof encounters.baseDir).toBe('string');
|
|
||||||
expect(encounters.baseDir.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should compile nodes from .yarn files', () => {
|
|
||||||
const nodeTitles = Object.keys(encounters.program.nodes);
|
|
||||||
expect(nodeTitles.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should contain expected nodes from story.yarn', () => {
|
|
||||||
const nodeTitles = Object.keys(encounters.program.nodes);
|
|
||||||
expect(nodeTitles).toContain('Start');
|
|
||||||
expect(nodeTitles).toContain('Scene1');
|
|
||||||
expect(nodeTitles).toContain('Scene2');
|
|
||||||
expect(nodeTitles).toContain('Scene3');
|
|
||||||
expect(nodeTitles).toContain('Scene4');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have instructions in each node', () => {
|
|
||||||
for (const [title, node] of Object.entries(encounters.program.nodes)) {
|
|
||||||
if ('instructions' in node && Array.isArray((node as any).instructions)) {
|
|
||||||
expect((node as any).instructions.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have Start node with jump instruction to Scene1', () => {
|
|
||||||
const startNode = encounters.program.nodes['Start'];
|
|
||||||
if ('instructions' in startNode) {
|
|
||||||
const jumpInstruction = startNode.instructions.find(
|
|
||||||
(instr) => instr.op === 'jump',
|
|
||||||
);
|
|
||||||
expect(jumpInstruction).toBeDefined();
|
|
||||||
expect(jumpInstruction!.target).toBe('Scene1');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
import { parseShapeString } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
|
||||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/system/utils/shape-collision';
|
||||||
import {
|
import {
|
||||||
createGridInventory,
|
createGridInventory,
|
||||||
placeItem,
|
placeItem,
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
validatePlacement,
|
validatePlacement,
|
||||||
type GridInventory,
|
type GridInventory,
|
||||||
type InventoryItem,
|
type InventoryItem,
|
||||||
} from '@/samples/slay-the-spire-like/grid-inventory';
|
} from '@/samples/slay-the-spire-like/system/grid-inventory';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: create a test inventory item.
|
* Helper: create a test inventory item.
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { generatePointCrawlMap, hasPath } from '@/samples/slay-the-spire-like/map/generator';
|
import { generatePointCrawlMap, hasPath } from '@/samples/slay-the-spire-like/system/map/generator';
|
||||||
import { MapNodeType, MapLayerType } from '@/samples/slay-the-spire-like/map/types';
|
import { MapNodeType, MapLayerType } from '@/samples/slay-the-spire-like/system/map/types';
|
||||||
|
import { createRNG } from '@/utils/rng';
|
||||||
|
import { encounters } from '@/samples/slay-the-spire-like/data/desert';
|
||||||
|
|
||||||
describe('generatePointCrawlMap', () => {
|
describe('generatePointCrawlMap', () => {
|
||||||
it('should generate a map with 10 layers', () => {
|
it('should generate a map with 10 layers', () => {
|
||||||
const map = generatePointCrawlMap(123);
|
const map = generatePointCrawlMap(createRNG(123), encounters);
|
||||||
expect(map.layers.length).toBe(10);
|
expect(map.layers.length).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have correct layer structure', () => {
|
it('should have correct layer structure', () => {
|
||||||
const map = generatePointCrawlMap(123);
|
const map = generatePointCrawlMap(createRNG(123), encounters);
|
||||||
const expectedStructure = [
|
const expectedStructure = [
|
||||||
'start',
|
'start',
|
||||||
MapLayerType.Wild,
|
MapLayerType.Wild,
|
||||||
|
|
@ -29,7 +31,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have correct node counts per layer', () => {
|
it('should have correct node counts per layer', () => {
|
||||||
const map = generatePointCrawlMap(123);
|
const map = generatePointCrawlMap(createRNG(123), encounters);
|
||||||
const expectedCounts = [1, 3, 3, 4, 3, 3, 4, 3, 3, 1];
|
const expectedCounts = [1, 3, 3, 4, 3, 3, 4, 3, 3, 1];
|
||||||
|
|
||||||
for (let i = 0; i < expectedCounts.length; i++) {
|
for (let i = 0; i < expectedCounts.length; i++) {
|
||||||
|
|
@ -38,7 +40,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have Start and End nodes with correct types', () => {
|
it('should have Start and End nodes with correct types', () => {
|
||||||
const map = generatePointCrawlMap(123);
|
const map = generatePointCrawlMap(createRNG(123), encounters);
|
||||||
const startNode = map.nodes.get('node-0-0');
|
const startNode = map.nodes.get('node-0-0');
|
||||||
const endNode = map.nodes.get('node-9-0');
|
const endNode = map.nodes.get('node-9-0');
|
||||||
|
|
||||||
|
|
@ -47,7 +49,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have wild layers with minion/elite/event types', () => {
|
it('should have wild layers with minion/elite/event types', () => {
|
||||||
const map = generatePointCrawlMap(123);
|
const map = generatePointCrawlMap(createRNG(123), encounters);
|
||||||
const wildLayerIndices = [1, 2, 4, 5, 7, 8];
|
const wildLayerIndices = [1, 2, 4, 5, 7, 8];
|
||||||
const validWildTypes = new Set([MapNodeType.Minion, MapNodeType.Elite, MapNodeType.Event]);
|
const validWildTypes = new Set([MapNodeType.Minion, MapNodeType.Elite, MapNodeType.Event]);
|
||||||
|
|
||||||
|
|
@ -62,7 +64,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have settlement layers with at least 1 camp, 1 shop, 1 curio', () => {
|
it('should have settlement layers with at least 1 camp, 1 shop, 1 curio', () => {
|
||||||
const map = generatePointCrawlMap(123);
|
const map = generatePointCrawlMap(createRNG(123), encounters);
|
||||||
const settlementLayerIndices = [3, 6];
|
const settlementLayerIndices = [3, 6];
|
||||||
|
|
||||||
for (const layerIdx of settlementLayerIndices) {
|
for (const layerIdx of settlementLayerIndices) {
|
||||||
|
|
@ -77,7 +79,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have Start connected to all 3 wild nodes', () => {
|
it('should have Start connected to all 3 wild nodes', () => {
|
||||||
const map = generatePointCrawlMap(42);
|
const map = generatePointCrawlMap(createRNG(42), encounters);
|
||||||
const startNode = map.nodes.get('node-0-0');
|
const startNode = map.nodes.get('node-0-0');
|
||||||
const wildLayer = map.layers[1];
|
const wildLayer = map.layers[1];
|
||||||
|
|
||||||
|
|
@ -86,7 +88,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have each wild node connect to 1 wild node in wild→wild layers', () => {
|
it('should have each wild node connect to 1 wild node in wild→wild layers', () => {
|
||||||
const map = generatePointCrawlMap(42);
|
const map = generatePointCrawlMap(createRNG(42), encounters);
|
||||||
const wildToWildTransitions = [
|
const wildToWildTransitions = [
|
||||||
{ src: 1, tgt: 2 },
|
{ src: 1, tgt: 2 },
|
||||||
{ src: 4, tgt: 5 },
|
{ src: 4, tgt: 5 },
|
||||||
|
|
@ -105,7 +107,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have each wild node connect to 2 settlement nodes in wild→settlement layers', () => {
|
it('should have each wild node connect to 2 settlement nodes in wild→settlement layers', () => {
|
||||||
const map = generatePointCrawlMap(42);
|
const map = generatePointCrawlMap(createRNG(42), encounters);
|
||||||
const wildToSettlementTransitions = [
|
const wildToSettlementTransitions = [
|
||||||
{ src: 2, tgt: 3 },
|
{ src: 2, tgt: 3 },
|
||||||
{ src: 5, tgt: 6 },
|
{ src: 5, tgt: 6 },
|
||||||
|
|
@ -125,7 +127,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have settlement nodes connect correctly (1-2-2-1 pattern)', () => {
|
it('should have settlement nodes connect correctly (1-2-2-1 pattern)', () => {
|
||||||
const map = generatePointCrawlMap(42);
|
const map = generatePointCrawlMap(createRNG(42), encounters);
|
||||||
const settlementToWildTransitions = [
|
const settlementToWildTransitions = [
|
||||||
{ src: 3, tgt: 4 },
|
{ src: 3, tgt: 4 },
|
||||||
{ src: 6, tgt: 7 },
|
{ src: 6, tgt: 7 },
|
||||||
|
|
@ -153,7 +155,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have all 3 wild nodes connect to End', () => {
|
it('should have all 3 wild nodes connect to End', () => {
|
||||||
const map = generatePointCrawlMap(42);
|
const map = generatePointCrawlMap(createRNG(42), encounters);
|
||||||
const lastWildLayer = map.layers[8];
|
const lastWildLayer = map.layers[8];
|
||||||
const endNode = map.nodes.get('node-9-0');
|
const endNode = map.nodes.get('node-9-0');
|
||||||
|
|
||||||
|
|
@ -164,7 +166,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have all nodes reachable from Start and can reach End', () => {
|
it('should have all nodes reachable from Start and can reach End', () => {
|
||||||
const map = generatePointCrawlMap(123);
|
const map = generatePointCrawlMap(createRNG(123), encounters);
|
||||||
const startId = 'node-0-0';
|
const startId = 'node-0-0';
|
||||||
const endId = 'node-9-0';
|
const endId = 'node-9-0';
|
||||||
|
|
||||||
|
|
@ -176,7 +178,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not have crossing edges in wild→wild transitions', () => {
|
it('should not have crossing edges in wild→wild transitions', () => {
|
||||||
const map = generatePointCrawlMap(12345);
|
const map = generatePointCrawlMap(createRNG(12345), encounters);
|
||||||
const wildToWildTransitions = [
|
const wildToWildTransitions = [
|
||||||
{ src: 1, tgt: 2 },
|
{ src: 1, tgt: 2 },
|
||||||
{ src: 4, tgt: 5 },
|
{ src: 4, tgt: 5 },
|
||||||
|
|
@ -214,7 +216,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not have crossing edges in wild→settlement transitions', () => {
|
it('should not have crossing edges in wild→settlement transitions', () => {
|
||||||
const map = generatePointCrawlMap(12345);
|
const map = generatePointCrawlMap(createRNG(12345), encounters);
|
||||||
const wildToSettlementTransitions = [
|
const wildToSettlementTransitions = [
|
||||||
{ src: 2, tgt: 3 },
|
{ src: 2, tgt: 3 },
|
||||||
{ src: 5, tgt: 6 },
|
{ src: 5, tgt: 6 },
|
||||||
|
|
@ -251,7 +253,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not have crossing edges in settlement→wild transitions', () => {
|
it('should not have crossing edges in settlement→wild transitions', () => {
|
||||||
const map = generatePointCrawlMap(12345);
|
const map = generatePointCrawlMap(createRNG(12345), encounters);
|
||||||
const settlementToWildTransitions = [
|
const settlementToWildTransitions = [
|
||||||
{ src: 3, tgt: 4 },
|
{ src: 3, tgt: 4 },
|
||||||
{ src: 6, tgt: 7 },
|
{ src: 6, tgt: 7 },
|
||||||
|
|
@ -288,7 +290,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should assign encounters to all non-Start/End nodes', () => {
|
it('should assign encounters to all non-Start/End nodes', () => {
|
||||||
const map = generatePointCrawlMap(456);
|
const map = generatePointCrawlMap(createRNG(456), encounters);
|
||||||
|
|
||||||
for (const node of map.nodes.values()) {
|
for (const node of map.nodes.values()) {
|
||||||
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
|
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
|
||||||
|
|
@ -306,7 +308,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
it('should assign encounters to all nodes across multiple seeds', () => {
|
it('should assign encounters to all nodes across multiple seeds', () => {
|
||||||
// Test multiple seeds to ensure no random failure
|
// Test multiple seeds to ensure no random failure
|
||||||
for (let seed = 0; seed < 20; seed++) {
|
for (let seed = 0; seed < 20; seed++) {
|
||||||
const map = generatePointCrawlMap(seed);
|
const map = generatePointCrawlMap(createRNG(seed), encounters);
|
||||||
|
|
||||||
for (const node of map.nodes.values()) {
|
for (const node of map.nodes.values()) {
|
||||||
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
|
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
|
||||||
|
|
@ -321,7 +323,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
|
|
||||||
it('should minimize same-layer repetitions in wild layer pairs', () => {
|
it('should minimize same-layer repetitions in wild layer pairs', () => {
|
||||||
// Test that wild layers in pairs (1-2, 4-5, 7-8) have minimal duplicate types within each layer
|
// Test that wild layers in pairs (1-2, 4-5, 7-8) have minimal duplicate types within each layer
|
||||||
const map = generatePointCrawlMap(12345);
|
const map = generatePointCrawlMap(createRNG(12345), encounters);
|
||||||
const wildPairIndices = [
|
const wildPairIndices = [
|
||||||
[1, 2],
|
[1, 2],
|
||||||
[4, 5],
|
[4, 5],
|
||||||
|
|
@ -351,7 +353,7 @@ describe('generatePointCrawlMap', () => {
|
||||||
|
|
||||||
it('should minimize adjacent repetitions in wild→wild connections', () => {
|
it('should minimize adjacent repetitions in wild→wild connections', () => {
|
||||||
// Test that wild nodes connected by wild→wild edges have different types
|
// Test that wild nodes connected by wild→wild edges have different types
|
||||||
const map = generatePointCrawlMap(12345);
|
const map = generatePointCrawlMap(createRNG(12345), encounters);
|
||||||
const wildToWildPairs = [
|
const wildToWildPairs = [
|
||||||
{ src: 1, tgt: 2 },
|
{ src: 1, tgt: 2 },
|
||||||
{ src: 4, tgt: 5 },
|
{ src: 4, tgt: 5 },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
import { parseShapeString } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
|
||||||
import {
|
import {
|
||||||
checkCollision,
|
checkCollision,
|
||||||
checkBoardCollision,
|
checkBoardCollision,
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
transformShape,
|
transformShape,
|
||||||
getOccupiedCells,
|
getOccupiedCells,
|
||||||
IDENTITY_TRANSFORM,
|
IDENTITY_TRANSFORM,
|
||||||
} from '@/samples/slay-the-spire-like/utils/shape-collision';
|
} from '@/samples/slay-the-spire-like/system/utils/shape-collision';
|
||||||
|
|
||||||
describe('parseShapeString', () => {
|
describe('parseShapeString', () => {
|
||||||
it('should parse a single cell with o', () => {
|
it('should parse a single cell with o', () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,436 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createMiddlewareChain, type MiddlewareChain } from '@/utils/middleware';
|
||||||
|
|
||||||
|
describe('createMiddlewareChain', () => {
|
||||||
|
describe('basic execution', () => {
|
||||||
|
it('should return context when no middlewares and no fallback', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ value: number }>();
|
||||||
|
const result = await chain.execute({ value: 42 });
|
||||||
|
|
||||||
|
expect(result).toEqual({ value: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call fallback when no middlewares', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ value: number }, string>(
|
||||||
|
async ctx => `value is ${ctx.value}`
|
||||||
|
);
|
||||||
|
const result = await chain.execute({ value: 42 });
|
||||||
|
|
||||||
|
expect(result).toBe('value is 42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass context to fallback', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ a: number; b: number }, number>(
|
||||||
|
async ctx => ctx.a + ctx.b
|
||||||
|
);
|
||||||
|
const result = await chain.execute({ a: 3, b: 7 });
|
||||||
|
|
||||||
|
expect(result).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('single middleware', () => {
|
||||||
|
it('should execute a single middleware', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ count: number }>();
|
||||||
|
chain.use(async (ctx, next) => {
|
||||||
|
ctx.count *= 2;
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ count: 5 });
|
||||||
|
|
||||||
|
expect(result.count).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow middleware to modify return value', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ value: number }, number>(
|
||||||
|
async ctx => ctx.value
|
||||||
|
);
|
||||||
|
chain.use(async (ctx, next) => {
|
||||||
|
const result = await next();
|
||||||
|
return result * 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ value: 21 });
|
||||||
|
|
||||||
|
expect(result).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow middleware to short-circuit without calling next', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ value: number }>();
|
||||||
|
chain.use(async (_ctx, _next) => {
|
||||||
|
return { value: 999 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ value: 1 });
|
||||||
|
|
||||||
|
expect(result.value).toBe(999);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multiple middlewares', () => {
|
||||||
|
it('should execute middlewares in order', async () => {
|
||||||
|
const order: number[] = [];
|
||||||
|
const chain = createMiddlewareChain<{ value: number }>();
|
||||||
|
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
order.push(1);
|
||||||
|
const result = await next();
|
||||||
|
order.push(4);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
order.push(2);
|
||||||
|
const result = await next();
|
||||||
|
order.push(3);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
await chain.execute({ value: 0 });
|
||||||
|
|
||||||
|
expect(order).toEqual([1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accumulate modifications through the chain', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ value: number }>();
|
||||||
|
|
||||||
|
chain.use(async (ctx, next) => {
|
||||||
|
ctx.value += 1;
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
chain.use(async (ctx, next) => {
|
||||||
|
ctx.value *= 2;
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
chain.use(async (ctx, next) => {
|
||||||
|
ctx.value += 3;
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ value: 0 });
|
||||||
|
|
||||||
|
expect(result.value).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow middleware to modify result on the way back', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ base: number }, number>(
|
||||||
|
async ctx => ctx.base
|
||||||
|
);
|
||||||
|
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
const result = await next();
|
||||||
|
return result + 10;
|
||||||
|
});
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
const result = await next();
|
||||||
|
return result * 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ base: 5 });
|
||||||
|
|
||||||
|
expect(result).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow middleware to short-circuit in the middle', async () => {
|
||||||
|
const executed: number[] = [];
|
||||||
|
const chain = createMiddlewareChain<{ value: number }>();
|
||||||
|
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
executed.push(1);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
chain.use(async () => {
|
||||||
|
executed.push(2);
|
||||||
|
return { value: -1 };
|
||||||
|
});
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
executed.push(3);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ value: 100 });
|
||||||
|
|
||||||
|
expect(result.value).toBe(-1);
|
||||||
|
expect(executed).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nested next calls', () => {
|
||||||
|
it('should advance index on each next call, skipping remaining middlewares', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ counter: number }>();
|
||||||
|
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
await next();
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ counter: 0 });
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow middleware to call next conditionally', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ skip: boolean; value: number }>();
|
||||||
|
|
||||||
|
chain.use(async (ctx, next) => {
|
||||||
|
if (ctx.skip) {
|
||||||
|
return { value: -1 };
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
chain.use(async (ctx, next) => {
|
||||||
|
ctx.value += 10;
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultA = await chain.execute({ skip: true, value: 0 });
|
||||||
|
const resultB = await chain.execute({ skip: false, value: 0 });
|
||||||
|
|
||||||
|
expect(resultA.value).toBe(-1);
|
||||||
|
expect(resultB.value).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle middleware that awaits next multiple times with a fallback', async () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const chain = createMiddlewareChain<{ value: number }, string[]>(
|
||||||
|
async _ctx => log
|
||||||
|
);
|
||||||
|
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
log.push('before');
|
||||||
|
await next();
|
||||||
|
log.push('after-first');
|
||||||
|
await next();
|
||||||
|
log.push('after-second');
|
||||||
|
return log;
|
||||||
|
});
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
log.push('mw2');
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ value: 0 });
|
||||||
|
|
||||||
|
expect(result).toEqual(['before', 'mw2', 'after-first', 'after-second']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fallback result on second next call when no more middlewares remain', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ value: number }, number>(
|
||||||
|
async ctx => ctx.value * 10
|
||||||
|
);
|
||||||
|
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
await next();
|
||||||
|
return await next();
|
||||||
|
});
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ value: 5 });
|
||||||
|
|
||||||
|
expect(result).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fallback result on second next call when no more middlewares remain', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ value: number }, number>(
|
||||||
|
async ctx => ctx.value * 10
|
||||||
|
);
|
||||||
|
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
await next();
|
||||||
|
return await next();
|
||||||
|
});
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ value: 5 });
|
||||||
|
|
||||||
|
expect(result).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('async behavior', () => {
|
||||||
|
it('should handle async middlewares', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ value: number }>();
|
||||||
|
|
||||||
|
chain.use(async (ctx, next) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
ctx.value += 1;
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
chain.use(async (ctx, next) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
ctx.value += 2;
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ value: 0 });
|
||||||
|
|
||||||
|
expect(result.value).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle async fallback', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ value: number }, number>(
|
||||||
|
async ctx => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
return ctx.value * 10;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await chain.execute({ value: 5 });
|
||||||
|
|
||||||
|
expect(result).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should propagate errors from middleware', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ value: number }>();
|
||||||
|
|
||||||
|
chain.use(async () => {
|
||||||
|
throw new Error('middleware error');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(chain.execute({ value: 1 })).rejects.toThrow('middleware error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should propagate errors from fallback', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ value: number }, number>(
|
||||||
|
async () => {
|
||||||
|
throw new Error('fallback error');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(chain.execute({ value: 1 })).rejects.toThrow('fallback error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow middleware to catch errors from downstream', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ value: number }>();
|
||||||
|
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
try {
|
||||||
|
return await next();
|
||||||
|
} catch {
|
||||||
|
return { value: -1 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
chain.use(async () => {
|
||||||
|
throw new Error('downstream error');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ value: 1 });
|
||||||
|
|
||||||
|
expect(result.value).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('return type override', () => {
|
||||||
|
it('should support different TReturn type than TContext', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ name: string }, string>(
|
||||||
|
async ctx => `Hello, ${ctx.name}!`
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await chain.execute({ name: 'World' });
|
||||||
|
|
||||||
|
expect(result).toBe('Hello, World!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow middleware to transform return type', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ items: number[] }, number>(
|
||||||
|
async ctx => ctx.items.reduce((a, b) => a + b, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
const sum = await next();
|
||||||
|
return sum * 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ items: [1, 2, 3] });
|
||||||
|
|
||||||
|
expect(result).toBe(12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reusability', () => {
|
||||||
|
it('should reset index on each execute call', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ count: number }>();
|
||||||
|
|
||||||
|
chain.use(async (ctx, next) => {
|
||||||
|
ctx.count += 1;
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultA = await chain.execute({ count: 0 });
|
||||||
|
const resultB = await chain.execute({ count: 0 });
|
||||||
|
|
||||||
|
expect(resultA.count).toBe(1);
|
||||||
|
expect(resultB.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should share middlewares across execute calls', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ log: string[] }>();
|
||||||
|
|
||||||
|
chain.use(async (ctx, next) => {
|
||||||
|
ctx.log.push('always');
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
await chain.execute({ log: [] });
|
||||||
|
await chain.execute({ log: [] });
|
||||||
|
|
||||||
|
expect(chain).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle empty context object', async () => {
|
||||||
|
const chain = createMiddlewareChain<Record<string, never>>();
|
||||||
|
const result = await chain.execute({});
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle middleware that returns a completely different object', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ x: number }, { y: string }>(
|
||||||
|
async () => ({ y: 'default' })
|
||||||
|
);
|
||||||
|
|
||||||
|
chain.use(async (_ctx, next) => {
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ x: 42 });
|
||||||
|
|
||||||
|
expect(result).toEqual({ y: 'default' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle middleware that mutates context without returning', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ value: number }>(
|
||||||
|
async ctx => ctx
|
||||||
|
);
|
||||||
|
|
||||||
|
chain.use(async (ctx, next) => {
|
||||||
|
ctx.value = 100;
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ value: 0 });
|
||||||
|
|
||||||
|
expect(result.value).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when middleware does not call next or return', async () => {
|
||||||
|
const chain = createMiddlewareChain<{ value: number }>();
|
||||||
|
|
||||||
|
chain.use(async (ctx) => {
|
||||||
|
ctx.value = 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chain.execute({ value: 0 });
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue