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 {RNG} from "@/utils/rng";
|
||||
import {ReadonlyRNG} from "@/utils/rng";
|
||||
|
||||
export type Region = {
|
||||
id: string;
|
||||
|
|
@ -107,7 +107,7 @@ export function applyAlign<TMeta>(region: Region, parts: Record<string, Part<TMe
|
|||
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;
|
||||
|
||||
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,瞬间生效
|
||||
|
||||
# temporary: 施加buff,下回合开始时失效
|
||||
# lingering: 施加buff,下回合开始时失去1层
|
||||
# permanent: 施加buff
|
||||
# posture: 施加buff,每受到1点伤害移除1层
|
||||
# card: 不施加buff,对玩家时在玩家弃牌堆创建同名卡牌,对敌人无效(敌人没有牌堆)
|
||||
# cardDraw: 不施加buff,在抓牌堆洗入同名卡牌
|
||||
# cardHand:不施加buff,在玩家手牌中创建同名卡牌
|
||||
# item: 施加buff到周围物品,永久生效
|
||||
# itemUntilPlayed: 施加buff到周围物品,物品被打出后失效
|
||||
|
||||
id, name, description, timing
|
||||
string, string, string, 'instant'|'temporary'|'lingering'|'permanent'|'posture'|'card'|'cardDraw'|'cardHand'|'item'|'itemUntilPlayed'
|
||||
# item: 施加buff到周围物品,永久生效
|
||||
# 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
|
||||
defend, 防御, 抵消下次行动前受到的伤害, posture
|
||||
spike, 尖刺, 对攻击者造成X点伤害, permanent
|
||||
venom, 蛇毒, 同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害, cardHand
|
||||
venom, 蛇毒, 同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害, instant
|
||||
curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, lingering
|
||||
aim, 瞄准, 造成双倍伤害,受伤时失去等量瞄准, posture
|
||||
roll, 滚动, 攻击时每消耗10点滚动造成等量伤害, posture
|
||||
rollDamage, 滚动攻击, 消耗滚动层数造成的伤害, instant
|
||||
vultureEye, 秃鹫之眼, 抓到时获得3层暴露(临时debuff,受到的伤害+1/每层), cardDraw
|
||||
vultureEye, 秃鹫之眼, 抓到时获得3层暴露(临时debuff,受到的伤害+1/每层), instant
|
||||
tailSting, 尾刺, 攻击时,伤害提升X, posture
|
||||
energyDrain, 能量吸取, 受伤时,玩家失去X点能量, lingering
|
||||
molt, 脱皮, 若脱皮达到生命上限则怪物逃跑, posture
|
||||
|
|
@ -35,14 +37,14 @@ crossbow, 十字弩连击, 对同一目标打出其他十字弩, instant
|
|||
defendNext, 下回合防御, 下回合开始时获得防御, temporary
|
||||
damageReduce, 减伤, 本回合受到的伤害减少X, temporary
|
||||
removeWound, 移除伤口, 从牌堆或弃牌堆移除X张伤口, instant
|
||||
attackBuff, 攻击增益, 周围物品的攻击+X, itemUntilPlayed
|
||||
defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlayed
|
||||
attackBuff, 攻击增益, 周围物品的攻击+X, itemUntilPlay
|
||||
defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlay
|
||||
gainEnergy, 获得能量, 获得X点能量, instant
|
||||
energyNext, 下回合获能量, 下回合开始时获得X点能量, temporary
|
||||
drawNext, 下回合抓牌, 下回合开始时抓X张牌, temporary
|
||||
defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlayed
|
||||
defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlay
|
||||
drawChoice, 选择抓牌, 从牌堆周围物品的牌中选择一张加入手牌, instant
|
||||
burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlayed
|
||||
attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlayed
|
||||
burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlay
|
||||
attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlay
|
||||
transformRandom, 随机变牌, 选择一张牌随机变为周围物品的牌, instant
|
||||
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
|
||||
|
||||
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]
|
||||
仙人掌怪,defend,false,attack,,[spike;1],[self;defend;8]
|
||||
仙人掌怪,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 encounterDesertCsv from './encounterDesert.csv';
|
||||
import enemyDesertCsv from './enemyDesert.csv';
|
||||
import effectDesertCsv from './effectDesert.csv';
|
||||
import cardDesertCsv from './cardDesert.csv';
|
||||
import * as desert from './desert';
|
||||
|
||||
export const heroItemFighter1Data = heroItemFighter1Csv();
|
||||
export const encounterDesertData = encounterDesertCsv();
|
||||
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';
|
||||
export default {
|
||||
desert
|
||||
}
|
||||
|
|
@ -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 {
|
||||
generateDeckFromInventory,
|
||||
createStatusCard,
|
||||
createDeckRegions,
|
||||
createCard,
|
||||
createPlayerDeck,
|
||||
generateCardId,
|
||||
} 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).
|
||||
* 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>,
|
||||
itemId: string
|
||||
): Map<string, InventoryItem<TMeta>> {
|
||||
|
|
@ -18,7 +18,7 @@ export interface CellCoordinate {
|
|||
* An item placed on the grid inventory.
|
||||
* @template TMeta - Optional metadata type for game-specific data
|
||||
*/
|
||||
export interface InventoryItem<TMeta = Record<string, unknown>> {
|
||||
export interface InventoryItem<TMeta> {
|
||||
/** Unique item identifier */
|
||||
id: string;
|
||||
/** 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.
|
||||
* @template TMeta - Optional metadata type for items
|
||||
*/
|
||||
export interface GridInventory<TMeta = Record<string, unknown>> {
|
||||
export interface GridInventory<TMeta> {
|
||||
/** Board width in cells */
|
||||
width: number;
|
||||
/** Board height in cells */
|
||||
|
|
@ -1,16 +1,11 @@
|
|||
import { Mulberry32RNG, type RNG } from '@/utils/rng';
|
||||
import encounterDesertCsvAccessor, { type EncounterDesert } from '../data/encounterDesert.csv';
|
||||
import { ReadonlyRNG } from '@/utils/rng';
|
||||
import { MapNodeType, MapLayerType } from './types';
|
||||
import type { MapLayer, MapNode, PointCrawlMap, MapGenerationConfig } from './types';
|
||||
import {EncounterData} from "@/samples/slay-the-spire-like/system/types";
|
||||
|
||||
const encounterDesertCsv = encounterDesertCsvAccessor();
|
||||
|
||||
/** Pre-indexed encounters by type */
|
||||
const encountersByType = buildEncounterIndex();
|
||||
|
||||
function buildEncounterIndex(): Map<string, EncounterDesert[]> {
|
||||
const index = new Map<string, EncounterDesert[]>();
|
||||
for (const encounter of encounterDesertCsv) {
|
||||
function buildEncounterIndex(src: Iterable<EncounterData>): Map<string, EncounterData[]> {
|
||||
const index = new Map<string, EncounterData[]>();
|
||||
for (const encounter of src) {
|
||||
const type = encounter.type;
|
||||
if (!index.has(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.
|
||||
* 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--) {
|
||||
const j = rng.nextInt(i + 1);
|
||||
[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.
|
||||
* Returns undefined if no matching encounter exists.
|
||||
*/
|
||||
function pickEncounterForNode(type: MapNodeType, rng: RNG): EncounterDesert | undefined {
|
||||
const encounterType = NODE_TYPE_TO_ENCOUNTER[type];
|
||||
if (!encounterType) return undefined;
|
||||
|
||||
const pool = encountersByType.get(encounterType);
|
||||
function pickEncounterForNode(pool: EncounterData[] | undefined, rng: ReadonlyRNG): EncounterData | undefined {
|
||||
if (!pool || pool.length === 0) return undefined;
|
||||
|
||||
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
|
||||
* - Wild nodes connect to 1 wild node or 2 settlement nodes
|
||||
*
|
||||
* @param seed Random seed for reproducibility
|
||||
*/
|
||||
export function generatePointCrawlMap(seed?: number): PointCrawlMap {
|
||||
const rng = new Mulberry32RNG(seed ?? Date.now());
|
||||
const actualSeed = rng.getSeed();
|
||||
|
||||
export function generatePointCrawlMap(rng: ReadonlyRNG, src: Iterable<EncounterData>): PointCrawlMap {
|
||||
const encounters = buildEncounterIndex(src);
|
||||
const layers: MapLayer[] = [];
|
||||
const nodes = new Map<string, MapNode>();
|
||||
|
||||
|
|
@ -136,13 +124,13 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap {
|
|||
for (let j = 0; j < structure.count; j++) {
|
||||
const id = `node-${i}-${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 = {
|
||||
id,
|
||||
layerIndex: i,
|
||||
type,
|
||||
childIds: [],
|
||||
...(encounter ? { encounter: { name: encounter.name, description: encounter.description } } : {}),
|
||||
encounter
|
||||
};
|
||||
nodes.set(id, node);
|
||||
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',
|
||||
_nodeIndex: number,
|
||||
_layerCount: number,
|
||||
rng: RNG,
|
||||
rng: ReadonlyRNG,
|
||||
preGeneratedTypes?: MapNodeType[],
|
||||
nodeIndex?: number,
|
||||
settlementTypes?: MapNodeType[],
|
||||
|
|
@ -213,7 +201,7 @@ function resolveNodeType(
|
|||
* Picks a random type for a wild node based on configured weights.
|
||||
* Default: minion: 50%, elite: 25%, event: 25%
|
||||
*/
|
||||
function pickWildNodeType(rng: RNG): MapNodeType {
|
||||
function pickWildNodeType(rng: ReadonlyRNG): MapNodeType {
|
||||
const weights = DEFAULT_CONFIG.wildNodeTypeWeights;
|
||||
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).
|
||||
* Returns two arrays of 3 node types each.
|
||||
*/
|
||||
function generateWildPair(rng: RNG): [MapNodeType[], MapNodeType[]] {
|
||||
function generateWildPair(rng: ReadonlyRNG): [MapNodeType[], MapNodeType[]] {
|
||||
const layer1Types: 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.
|
||||
*/
|
||||
function generateOptimalWildPair(
|
||||
rng: RNG,
|
||||
rng: ReadonlyRNG,
|
||||
attempts = 3
|
||||
): [MapNodeType[], MapNodeType[]] {
|
||||
let bestLayer1: MapNodeType[] = [];
|
||||
|
|
@ -313,7 +301,7 @@ function generateOptimalWildPair(
|
|||
* The 4th node is randomly chosen from the three.
|
||||
* 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 randomType = requiredTypes[rng.nextInt(3)];
|
||||
const types = [...requiredTypes, randomType];
|
||||
|
|
@ -325,7 +313,7 @@ function generateSettlementTypes(rng: RNG): MapNodeType[] {
|
|||
* The 4th node is randomly chosen from the three.
|
||||
* @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
|
||||
const shuffledIndices = fisherYatesShuffle([0, 1, 2, 3], rng);
|
||||
|
||||
|
|
@ -347,7 +335,7 @@ function generateLayerEdges(
|
|||
sourceLayer: MapLayer,
|
||||
targetLayer: MapLayer,
|
||||
nodes: Map<string, MapNode>,
|
||||
rng: RNG
|
||||
rng: ReadonlyRNG
|
||||
): void {
|
||||
// Settlement types are now pre-generated during node creation
|
||||
// No need to assign them here anymore
|
||||
|
|
@ -402,7 +390,7 @@ function connectWildToWild(
|
|||
function connectWildToSettlement(
|
||||
wildLayer: MapLayer,
|
||||
settlementLayer: MapLayer,
|
||||
_rng: RNG
|
||||
_rng: ReadonlyRNG
|
||||
): void {
|
||||
// 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]}
|
||||
|
|
@ -427,7 +415,7 @@ function connectWildToSettlement(
|
|||
function connectSettlementToWild(
|
||||
settlementLayer: MapLayer,
|
||||
wildLayer: MapLayer,
|
||||
_rng: RNG
|
||||
_rng: ReadonlyRNG
|
||||
): void {
|
||||
// Non-crossing pattern: s0→w0, s1→w0,w1, s2→w1,w2, s3→w2
|
||||
// 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.
|
||||
*/
|
||||
|
|
@ -33,10 +35,7 @@ export interface MapNode {
|
|||
/** IDs of nodes in the next layer this node connects to */
|
||||
childIds: string[];
|
||||
/** Encounter data assigned to this node (from encounter CSV) */
|
||||
encounter?: {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
encounter?: EncounterData;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -61,8 +60,6 @@ export interface PointCrawlMap {
|
|||
layers: MapLayer[];
|
||||
/** All nodes keyed by ID */
|
||||
nodes: Map<string, MapNode>;
|
||||
/** RNG seed used for generation (for reproducibility) */
|
||||
seed: number;
|
||||
/** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */
|
||||
parentIndex?: Map<string, string[]>;
|
||||
}
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
import { Mulberry32RNG, type RNG } from '@/utils/rng';
|
||||
import { generatePointCrawlMap, getNode } from '../map/generator';
|
||||
import type { MapNode } from '../map/types';
|
||||
import { getNode } from '../map/generator';
|
||||
import type {MapNode, PointCrawlMap} from '../map/types';
|
||||
import { placeItem, validatePlacement, createGridInventory, removeItem as gridRemoveItem } from '../grid-inventory/transform';
|
||||
import type { GameItem, GameItemMeta, RunMutationResult, RunState } from './types';
|
||||
import type { GridInventory } from '../grid-inventory/types';
|
||||
import { IDENTITY_TRANSFORM, type Transform2D } from '../utils/shape-collision';
|
||||
import { parseShapeString, type ParsedShape } from '../utils/parse-shape';
|
||||
import type { HeroItemFighter1 } from '../data/heroItemFighter1.csv';
|
||||
import { heroItemFighter1Data } from '../data';
|
||||
import {ItemData} from "@/samples/slay-the-spire-like/system/types";
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
|
|
@ -28,21 +26,13 @@ const INVENTORY_HEIGHT = 4;
|
|||
const DEFAULT_MAX_HP = 50;
|
||||
const DEFAULT_GOLD = 50;
|
||||
|
||||
/** Starter items to give the player at the beginning of a run. */
|
||||
const STARTER_ITEM_NAMES = ['治疗药剂', '绷带', '水袋', '短刀', '剑'];
|
||||
|
||||
// -- Run creation --
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const actualSeed = seed ?? new Mulberry32RNG().nextInt(2 ** 31);
|
||||
const map = generatePointCrawlMap(actualSeed);
|
||||
|
||||
export function createRunState(map: PointCrawlMap, starterItems: ItemData[]): RunState {
|
||||
// Find the start node
|
||||
const startNode = map.layers[0].nodes[0];
|
||||
|
||||
|
|
@ -51,10 +41,7 @@ export function createRunState(seed?: number, rng?: RNG): RunState {
|
|||
const idCounter = { value: 0 };
|
||||
|
||||
// Place starter items
|
||||
for (const itemName of STARTER_ITEM_NAMES) {
|
||||
const itemData = findItemByName(itemName);
|
||||
if (!itemData) continue;
|
||||
|
||||
for (const itemData of starterItems) {
|
||||
const shape = parseShapeString(itemData.shape);
|
||||
const itemInstance = tryPlaceItemInInventory(inventory, itemData, shape, idCounter);
|
||||
if (!itemInstance) {
|
||||
|
|
@ -63,7 +50,6 @@ export function createRunState(seed?: number, rng?: RNG): RunState {
|
|||
}
|
||||
|
||||
return {
|
||||
seed: actualSeed,
|
||||
map,
|
||||
player: {
|
||||
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.
|
||||
*/
|
||||
export function addItemFromCsv(
|
||||
export function addItem(
|
||||
runState: RunState,
|
||||
itemData: HeroItemFighter1
|
||||
itemData: ItemData
|
||||
): GameItem | undefined {
|
||||
const shape = parseShapeString(itemData.shape);
|
||||
return tryPlaceItemInInventory(runState.inventory, itemData, shape, runState._idCounter);
|
||||
|
|
@ -277,13 +263,6 @@ export function isAtEndNode(runState: RunState): boolean {
|
|||
|
||||
// -- 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.
|
||||
*/
|
||||
|
|
@ -298,7 +277,7 @@ function generateInstanceId(counter: { value: number }): string {
|
|||
*/
|
||||
function tryPlaceItemInInventory(
|
||||
inventory: GridInventory<GameItemMeta>,
|
||||
itemData: HeroItemFighter1,
|
||||
itemData: ItemData,
|
||||
shape: ParsedShape,
|
||||
idCounter: { value: number }
|
||||
): GameItem | undefined {
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { PointCrawlMap } from '../map/types';
|
||||
import type { GridInventory, InventoryItem } from '../grid-inventory/types';
|
||||
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.).
|
||||
|
|
@ -35,9 +35,11 @@ export interface EncounterState {
|
|||
*/
|
||||
export interface GameItemMeta {
|
||||
/** Original CSV item data */
|
||||
itemData: HeroItemFighter1;
|
||||
itemData: ItemData;
|
||||
/** Parsed shape for grid placement */
|
||||
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.
|
||||
*/
|
||||
export interface RunState {
|
||||
/** RNG seed used for map generation */
|
||||
seed: number;
|
||||
/** Generated point crawl map */
|
||||
map: PointCrawlMap;
|
||||
/** 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 {
|
||||
applyDamage,
|
||||
applyDefend,
|
||||
applyBuff,
|
||||
removeBuff,
|
||||
updateBuffs,
|
||||
addEffect,
|
||||
addEntityEffect,
|
||||
addItemEffect,
|
||||
onEntityEffectUpkeep,
|
||||
onEntityPostureDamage,
|
||||
onPlayerItemEffectUpkeep,
|
||||
onItemPlay,
|
||||
onItemDiscard,
|
||||
getAliveEnemies,
|
||||
getCombatEntity,
|
||||
canPlayCard,
|
||||
playCard,
|
||||
areAllEnemiesDead,
|
||||
isPlayerDead,
|
||||
getModifiedAttackDamage,
|
||||
getModifiedDefendAmount,
|
||||
} from '@/samples/slay-the-spire-like/combat/effects';
|
||||
import type { CombatState, PlayerCombatState, EnemyState } from '@/samples/slay-the-spire-like/combat/types';
|
||||
import {
|
||||
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';
|
||||
payCardCost,
|
||||
} from '@/samples/slay-the-spire-like/system/combat/effects';
|
||||
import type { CombatEntity, CombatState, EffectTable, PlayerEntity, EnemyEntity } from '@/samples/slay-the-spire-like/system/combat/types';
|
||||
import type { EffectData } from '@/samples/slay-the-spire-like/system/types';
|
||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory/types';
|
||||
import type { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types';
|
||||
import type { ParsedShape } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
|
||||
import type { Transform2D } from '@/samples/slay-the-spire-like/system/utils/shape-collision';
|
||||
|
||||
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
|
||||
const shape = parseShapeString(shapeStr);
|
||||
function createEffect(id: string, lifecycle: EffectData['lifecycle']): EffectData {
|
||||
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 {
|
||||
itemData: {
|
||||
type: 'weapon',
|
||||
name,
|
||||
shape: shapeStr,
|
||||
costType: 'energy',
|
||||
costCount: 1,
|
||||
targetType: 'single',
|
||||
price: 10,
|
||||
desc: '测试',
|
||||
id: itemId,
|
||||
shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
|
||||
transform: { x: 0, y: 0, rotation: 0, flipX: false, flipY: false } as unknown as Transform2D,
|
||||
meta: {
|
||||
itemData: { id: itemId, type: 'weapon', name: itemId, shape: '1x1', card: createCard(cardId, costType, costCount), price: 0, description: '' },
|
||||
shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
|
||||
depletion: costType === 'uses' ? depletion : undefined,
|
||||
},
|
||||
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,
|
||||
function createInventory(items: InventoryItem<GameItemMeta>[]): GridInventory<GameItemMeta> {
|
||||
const map = new Map<string, InventoryItem<GameItemMeta>>();
|
||||
const occupied = new Set<string>();
|
||||
for (const item of items) {
|
||||
map.set(item.id, item);
|
||||
occupied.add(`${item.transform.x},${item.transform.y}`);
|
||||
}
|
||||
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 {
|
||||
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 createPlayerEntity(hp = 30, maxHp = 30): PlayerEntity {
|
||||
return {
|
||||
...createCombatEntity(hp, maxHp),
|
||||
energy: 3,
|
||||
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() {
|
||||
return new Mulberry32RNG(42);
|
||||
function createEnemyEntity(id: string, hp = 10, maxHp = 10): EnemyEntity {
|
||||
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('applyDamage', () => {
|
||||
it('should deal damage to player', () => {
|
||||
const state = createTestCombatState();
|
||||
applyDamage(state, 'player', 10);
|
||||
describe('addEffect', () => {
|
||||
it('should add a new effect to an empty table', () => {
|
||||
const table: EffectTable = {};
|
||||
const effect = createEffect('strength', 'temporary');
|
||||
|
||||
expect(state.player.hp).toBe(40);
|
||||
expect(state.player.damageTakenThisTurn).toBe(10);
|
||||
expect(state.player.damagedThisTurn).toBe(true);
|
||||
addEffect(table, effect, 3);
|
||||
|
||||
expect(table['strength']).toBeDefined();
|
||||
expect(table['strength'].data).toBe(effect);
|
||||
expect(table['strength'].stacks).toBe(3);
|
||||
});
|
||||
|
||||
it('should deal damage to enemy', () => {
|
||||
const state = createTestCombatState();
|
||||
const enemyId = state.enemyOrder[0];
|
||||
const enemy = state.enemies[enemyId];
|
||||
const initialHp = enemy.hp;
|
||||
it('should stack with existing effect of same id', () => {
|
||||
const table: EffectTable = {};
|
||||
const effect = createEffect('strength', 'lingering');
|
||||
|
||||
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', () => {
|
||||
const state = createTestCombatState();
|
||||
state.player.buffs['defend'] = 3;
|
||||
it('should remove effect when stacks reach 0', () => {
|
||||
const table: EffectTable = {};
|
||||
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(result.damageDealt).toBe(2);
|
||||
expect(state.player.hp).toBe(48);
|
||||
expect(table['strength']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should be fully absorbed by defend buff', () => {
|
||||
const state = createTestCombatState();
|
||||
state.player.buffs['defend'] = 10;
|
||||
it('should not add effect when stacks is 0', () => {
|
||||
const table: EffectTable = {};
|
||||
const effect = createEffect('strength', 'temporary');
|
||||
|
||||
applyDamage(state, 'player', 5);
|
||||
addEffect(table, effect, 0);
|
||||
|
||||
expect(state.player.hp).toBe(50);
|
||||
expect(state.player.buffs['defend']).toBe(5);
|
||||
expect(table['strength']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should be absorbed by defend buff on enemy', () => {
|
||||
const state = createTestCombatState();
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['defend'] = 4;
|
||||
it('should handle negative stacks', () => {
|
||||
const table: EffectTable = {};
|
||||
const effect = createEffect('weak', 'temporary');
|
||||
|
||||
const result = applyDamage(state, enemyId, 6);
|
||||
addEffect(table, effect, -2);
|
||||
|
||||
expect(result.blockedByDefend).toBe(4);
|
||||
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);
|
||||
expect(table['weak'].stacks).toBe(-2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyDefend', () => {
|
||||
it('should add defend stacks', () => {
|
||||
const buffs: Record<string, number> = {};
|
||||
applyDefend(buffs, 5);
|
||||
describe('addEntityEffect', () => {
|
||||
it('should add effect to entity.effects', () => {
|
||||
const entity = createCombatEntity();
|
||||
const effect = createEffect('vulnerable', 'lingering');
|
||||
|
||||
expect(buffs['defend']).toBe(5);
|
||||
});
|
||||
addEntityEffect(entity, effect, 2);
|
||||
|
||||
it('should stack with existing defend', () => {
|
||||
const buffs: Record<string, number> = { defend: 3 };
|
||||
applyDefend(buffs, 4);
|
||||
|
||||
expect(buffs['defend']).toBe(7);
|
||||
expect(entity.effects['vulnerable'].stacks).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyBuff / removeBuff', () => {
|
||||
it('should apply buff stacks', () => {
|
||||
const buffs: Record<string, number> = {};
|
||||
applyBuff(buffs, 'aim', 'lingering', 3);
|
||||
describe('addItemEffect', () => {
|
||||
it('should add effect to player.itemEffects[itemKey]', () => {
|
||||
const player = createPlayerEntity();
|
||||
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', () => {
|
||||
const buffs: Record<string, number> = { aim: 2 };
|
||||
applyBuff(buffs, 'aim', 'lingering', 3);
|
||||
it('should initialize itemEffects entry if not present', () => {
|
||||
const player = createPlayerEntity();
|
||||
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', () => {
|
||||
const buffs: Record<string, number> = { aim: 5 };
|
||||
const removed = removeBuff(buffs, 'aim', 3);
|
||||
it('should stack with existing item effect', () => {
|
||||
const player = createPlayerEntity();
|
||||
const effect = createEffect('adjacent-buff', 'itemTemporary');
|
||||
|
||||
expect(removed).toBe(3);
|
||||
expect(buffs['aim']).toBe(2);
|
||||
});
|
||||
addItemEffect(player, 'sword-1', effect, 2);
|
||||
addItemEffect(player, 'sword-1', effect, 3);
|
||||
|
||||
it('should remove buff fully when stacks exceed current', () => {
|
||||
const buffs: Record<string, number> = { aim: 2 };
|
||||
const removed = removeBuff(buffs, 'aim', 10);
|
||||
|
||||
expect(removed).toBe(2);
|
||||
expect(buffs['aim']).toBeUndefined();
|
||||
expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateBuffs', () => {
|
||||
it('should clear temporary buffs', () => {
|
||||
const buffs: Record<string, number> = { damageReduce: 3, defendNext: 2 };
|
||||
updateBuffs(buffs);
|
||||
describe('onEntityEffectUpkeep', () => {
|
||||
it('should remove temporary effects', () => {
|
||||
const entity = createCombatEntity();
|
||||
const tempEffect = createEffect('temp-shield', 'temporary');
|
||||
|
||||
expect(buffs['damageReduce']).toBeUndefined();
|
||||
expect(buffs['defendNext']).toBeUndefined();
|
||||
addEntityEffect(entity, tempEffect, 5);
|
||||
onEntityEffectUpkeep(entity);
|
||||
|
||||
expect(entity.effects['temp-shield']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should decrement lingering buffs', () => {
|
||||
const buffs: Record<string, number> = { curse: 3, energyDrain: 1 };
|
||||
updateBuffs(buffs);
|
||||
it('should decrement lingering effects by 1', () => {
|
||||
const entity = createCombatEntity();
|
||||
const lingeringEffect = createEffect('poison', 'lingering');
|
||||
|
||||
expect(buffs['curse']).toBe(2);
|
||||
expect(buffs['energyDrain']).toBeUndefined();
|
||||
addEntityEffect(entity, lingeringEffect, 3);
|
||||
onEntityEffectUpkeep(entity);
|
||||
|
||||
expect(entity.effects['poison'].stacks).toBe(2);
|
||||
});
|
||||
|
||||
it('should not affect permanent or posture buffs', () => {
|
||||
const buffs: Record<string, number> = { defend: 5, spike: 1 };
|
||||
updateBuffs(buffs);
|
||||
it('should remove lingering effects when stacks reach 0', () => {
|
||||
const entity = createCombatEntity();
|
||||
const lingeringEffect = createEffect('poison', 'lingering');
|
||||
|
||||
expect(buffs['defend']).toBe(5);
|
||||
expect(buffs['spike']).toBe(1);
|
||||
addEntityEffect(entity, lingeringEffect, 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', () => {
|
||||
it('should allow playing card with enough energy', () => {
|
||||
const state = createTestCombatState();
|
||||
const cardId = state.player.deck.hand[0];
|
||||
it('should allow playing energy card when player has enough energy', () => {
|
||||
const player = createPlayerEntity();
|
||||
player.energy = 3;
|
||||
const inventory = createInventory([]);
|
||||
|
||||
const result = canPlayCard(state, cardId);
|
||||
expect(result.canPlay).toBe(true);
|
||||
const result = canPlayCard(player, 'energy', 2, 'any', inventory);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject card not in hand', () => {
|
||||
const state = createTestCombatState();
|
||||
it('should reject playing energy card when player lacks energy', () => {
|
||||
const player = createPlayerEntity();
|
||||
player.energy = 1;
|
||||
const inventory = createInventory([]);
|
||||
|
||||
const result = canPlayCard(state, 'nonexistent-card');
|
||||
expect(result.canPlay).toBe(false);
|
||||
const result = canPlayCard(player, 'energy', 2, 'any', inventory);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject card with insufficient energy', () => {
|
||||
const state = createTestCombatState();
|
||||
state.player.energy = 0;
|
||||
const cardId = state.player.deck.hand[0];
|
||||
it('should allow playing uses card when item has remaining uses', () => {
|
||||
const player = createPlayerEntity();
|
||||
const item = createItem('potion-1', 'potion-card', 'uses', 3, 1);
|
||||
const inventory = createInventory([item]);
|
||||
|
||||
const card = state.player.deck.cards[cardId];
|
||||
if (card?.itemData?.costType === 'energy' && card.itemData.costCount > 0) {
|
||||
const result = canPlayCard(state, cardId);
|
||||
expect(result.canPlay).toBe(false);
|
||||
expect(result.reason).toBe('能量不足');
|
||||
}
|
||||
const result = canPlayCard(player, 'uses', 3, 'potion-1', inventory);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('should deduct energy cost when playing card', () => {
|
||||
const state = createTestCombatState();
|
||||
const cardId = state.player.deck.hand[0];
|
||||
const card = state.player.deck.cards[cardId];
|
||||
const initialEnergy = state.player.energy;
|
||||
describe('payCardCost', () => {
|
||||
it('should deduct energy for energy cost card', () => {
|
||||
const player = createPlayerEntity();
|
||||
player.energy = 3;
|
||||
const inventory = createInventory([]);
|
||||
|
||||
if (card?.itemData?.costType === 'energy') {
|
||||
const ctx = { state, rng: createSimpleRng() };
|
||||
const result = playCard(ctx, cardId);
|
||||
payCardCost(player, 'energy', 2, 'any', inventory);
|
||||
|
||||
if (result.success) {
|
||||
expect(state.player.energy).toBe(initialEnergy - card.itemData.costCount);
|
||||
}
|
||||
}
|
||||
expect(player.energy).toBe(1);
|
||||
});
|
||||
|
||||
it('should move card to discard pile after playing', () => {
|
||||
const state = createTestCombatState();
|
||||
const cardId = state.player.deck.hand[0];
|
||||
const ctx = { state, rng: createSimpleRng() };
|
||||
it('should increment depletion for uses cost card', () => {
|
||||
const player = createPlayerEntity();
|
||||
const item = createItem('potion-1', 'potion-card', 'uses', 3, 1);
|
||||
const inventory = createInventory([item]);
|
||||
|
||||
const result = playCard(ctx, cardId);
|
||||
payCardCost(player, 'uses', 3, 'potion-1', inventory);
|
||||
|
||||
if (result.success) {
|
||||
expect(state.player.deck.hand.includes(cardId)).toBe(false);
|
||||
expect(state.player.deck.discardPile.includes(cardId) || state.player.deck.exhaustPile.includes(cardId)).toBe(true);
|
||||
}
|
||||
});
|
||||
expect(item.meta?.depletion).toBe(4);
|
||||
});
|
||||
|
||||
describe('areAllEnemiesDead / isPlayerDead', () => {
|
||||
it('should detect all enemies dead', () => {
|
||||
const state = createTestCombatState();
|
||||
expect(areAllEnemiesDead(state)).toBe(false);
|
||||
it('should do nothing for none cost card', () => {
|
||||
const player = createPlayerEntity();
|
||||
player.energy = 3;
|
||||
const inventory = createInventory([]);
|
||||
|
||||
for (const enemyId of state.enemyOrder) {
|
||||
state.enemies[enemyId].isAlive = false;
|
||||
}
|
||||
expect(areAllEnemiesDead(state)).toBe(true);
|
||||
payCardCost(player, 'none', 0, 'any', inventory);
|
||||
|
||||
expect(player.energy).toBe(3);
|
||||
});
|
||||
|
||||
it('should detect player death', () => {
|
||||
const state = createTestCombatState();
|
||||
expect(isPlayerDead(state)).toBe(false);
|
||||
it('should handle missing item gracefully for uses cost', () => {
|
||||
const player = createPlayerEntity();
|
||||
const inventory = createInventory([]);
|
||||
|
||||
state.player.hp = 0;
|
||||
expect(isPlayerDead(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModifiedAttackDamage / getModifiedDefendAmount', () => {
|
||||
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', () => {
|
||||
const state = createTestCombatState();
|
||||
expect(getModifiedDefendAmount(state, 'some-card', 4)).toBe(4);
|
||||
});
|
||||
|
||||
it('should add item buff attack damage', () => {
|
||||
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);
|
||||
expect(() => payCardCost(player, 'uses', 1, 'missing', inventory)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
heroItemFighter1Data,
|
||||
encounterDesertData,
|
||||
enemyDesertData,
|
||||
enemyIntentDesertData,
|
||||
effectDesertData,
|
||||
statusCardDesertData,
|
||||
} from '@/samples/slay-the-spire-like/data';
|
||||
import data from '@/samples/slay-the-spire-like/data';
|
||||
|
||||
describe('heroItemFighter1.csv import', () => {
|
||||
it('should import data as an array', () => {
|
||||
expect(Array.isArray(heroItemFighter1Data)).toBe(true);
|
||||
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');
|
||||
describe('data import', () => {
|
||||
it('should import properly', () => {
|
||||
expect(data.desert.effects).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,33 +1,57 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
generateDeckFromInventory,
|
||||
createStatusCard,
|
||||
createCard,
|
||||
createDeckRegions,
|
||||
createPlayerDeck,
|
||||
generateCardId,
|
||||
} from '@/samples/slay-the-spire-like/deck/factory';
|
||||
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 } 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';
|
||||
} from '@/samples/slay-the-spire-like/system/deck/factory';
|
||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/system/grid-inventory';
|
||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory';
|
||||
import type { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types';
|
||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/system/utils/shape-collision';
|
||||
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.
|
||||
*/
|
||||
function createTestMeta(name: string, desc: string, shapeStr: string): GameItemMeta {
|
||||
const shape = parseShapeString(shapeStr);
|
||||
const itemData = createTestItemData(name.toLowerCase(), name, shapeStr, desc);
|
||||
return {
|
||||
itemData: {
|
||||
type: 'weapon',
|
||||
name,
|
||||
shape: shapeStr,
|
||||
costType: 'energy',
|
||||
costCount: 1,
|
||||
targetType: 'single',
|
||||
price: 10,
|
||||
desc,
|
||||
},
|
||||
itemData,
|
||||
shape,
|
||||
};
|
||||
}
|
||||
|
|
@ -70,21 +94,14 @@ describe('deck/factory', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('createStatusCard', () => {
|
||||
it('should create a card with null sourceItemId and itemData', () => {
|
||||
const card = createStatusCard('wound-1', '伤口', '无法被弃牌');
|
||||
|
||||
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', '眩晕', '跳过出牌阶段');
|
||||
describe('createCard', () => {
|
||||
it('should create a card with itemId and cardData', () => {
|
||||
const cardData = createTestCardData('wound', '伤口', '无法被弃牌');
|
||||
const card = createCard('wound-1', cardData, 0);
|
||||
|
||||
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.position).toEqual([]);
|
||||
});
|
||||
|
|
@ -98,10 +115,10 @@ describe('deck/factory', () => {
|
|||
const deck = generateDeckFromInventory(inv);
|
||||
|
||||
expect(Object.keys(deck.cards).length).toBe(6);
|
||||
expect(deck.drawPile.length).toBe(6);
|
||||
expect(deck.hand).toEqual([]);
|
||||
expect(deck.discardPile).toEqual([]);
|
||||
expect(deck.exhaustPile).toEqual([]);
|
||||
expect(deck.regions.drawPile.childIds.length).toBe(6);
|
||||
expect(deck.regions.hand.childIds).toEqual([]);
|
||||
expect(deck.regions.discardPile.childIds).toEqual([]);
|
||||
expect(deck.regions.exhaustPile.childIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should link cards to their source items', () => {
|
||||
|
|
@ -109,18 +126,18 @@ describe('deck/factory', () => {
|
|||
const deck = generateDeckFromInventory(inv);
|
||||
|
||||
const daggerCards = Object.values(deck.cards).filter(
|
||||
c => c.sourceItemId === 'dagger-1'
|
||||
c => c.itemId === 'dagger-1'
|
||||
);
|
||||
const shieldCards = Object.values(deck.cards).filter(
|
||||
c => c.sourceItemId === 'shield-1'
|
||||
c => c.itemId === 'shield-1'
|
||||
);
|
||||
|
||||
expect(daggerCards.length).toBe(2);
|
||||
expect(shieldCards.length).toBe(4);
|
||||
|
||||
// Verify item data
|
||||
expect(daggerCards[0].itemData?.name).toBe('短刀');
|
||||
expect(shieldCards[0].itemData?.name).toBe('盾');
|
||||
// Verify card data
|
||||
expect(daggerCards[0].cardData.name).toBe('短刀');
|
||||
expect(shieldCards[0].cardData.name).toBe('盾');
|
||||
});
|
||||
|
||||
it('should set displayName and description from item data', () => {
|
||||
|
|
@ -128,28 +145,28 @@ describe('deck/factory', () => {
|
|||
const deck = generateDeckFromInventory(inv);
|
||||
|
||||
for (const card of Object.values(deck.cards)) {
|
||||
expect(card.displayName).toBeTruthy();
|
||||
expect(card.description).toBeTruthy();
|
||||
expect(card.cardData.name).toBeTruthy();
|
||||
expect(card.cardData.desc).toBeTruthy();
|
||||
}
|
||||
|
||||
const daggerCard = Object.values(deck.cards).find(
|
||||
c => c.itemData?.name === '短刀'
|
||||
c => c.cardData.name === '短刀'
|
||||
);
|
||||
expect(daggerCard?.displayName).toBe('短刀');
|
||||
expect(daggerCard?.description).toBe('【攻击3】【攻击3】');
|
||||
expect(daggerCard?.cardData.name).toBe('短刀');
|
||||
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 deck = generateDeckFromInventory(inv);
|
||||
|
||||
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 uniqueKeys = new Set(cellKeys);
|
||||
expect(uniqueKeys.size).toBe(cellKeys.length);
|
||||
const ids = daggerCards.map(c => c.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('should handle empty inventory', () => {
|
||||
|
|
@ -157,19 +174,19 @@ describe('deck/factory', () => {
|
|||
const deck = generateDeckFromInventory(inv);
|
||||
|
||||
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', () => {
|
||||
const inv = createTestInventory();
|
||||
const deck = generateDeckFromInventory(inv);
|
||||
|
||||
for (const cardId of deck.drawPile) {
|
||||
for (const cardId of deck.regions.drawPile.childIds) {
|
||||
expect(deck.cards[cardId]).toBeDefined();
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
expect(deck.cards).toEqual({});
|
||||
expect(deck.drawPile).toEqual([]);
|
||||
expect(deck.hand).toEqual([]);
|
||||
expect(deck.discardPile).toEqual([]);
|
||||
expect(deck.exhaustPile).toEqual([]);
|
||||
expect(deck.regions.drawPile.childIds).toEqual([]);
|
||||
expect(deck.regions.hand.childIds).toEqual([]);
|
||||
expect(deck.regions.discardPile.childIds).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 { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
||||
import { parseShapeString } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
|
||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/system/utils/shape-collision';
|
||||
import {
|
||||
createGridInventory,
|
||||
placeItem,
|
||||
|
|
@ -14,7 +14,7 @@ import {
|
|||
validatePlacement,
|
||||
type GridInventory,
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { generatePointCrawlMap, hasPath } from '@/samples/slay-the-spire-like/map/generator';
|
||||
import { MapNodeType, MapLayerType } from '@/samples/slay-the-spire-like/map/types';
|
||||
import { generatePointCrawlMap, hasPath } from '@/samples/slay-the-spire-like/system/map/generator';
|
||||
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', () => {
|
||||
it('should generate a map with 10 layers', () => {
|
||||
const map = generatePointCrawlMap(123);
|
||||
const map = generatePointCrawlMap(createRNG(123), encounters);
|
||||
expect(map.layers.length).toBe(10);
|
||||
});
|
||||
|
||||
it('should have correct layer structure', () => {
|
||||
const map = generatePointCrawlMap(123);
|
||||
const map = generatePointCrawlMap(createRNG(123), encounters);
|
||||
const expectedStructure = [
|
||||
'start',
|
||||
MapLayerType.Wild,
|
||||
|
|
@ -29,7 +31,7 @@ describe('generatePointCrawlMap', () => {
|
|||
});
|
||||
|
||||
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];
|
||||
|
||||
for (let i = 0; i < expectedCounts.length; i++) {
|
||||
|
|
@ -38,7 +40,7 @@ describe('generatePointCrawlMap', () => {
|
|||
});
|
||||
|
||||
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 endNode = map.nodes.get('node-9-0');
|
||||
|
||||
|
|
@ -47,7 +49,7 @@ describe('generatePointCrawlMap', () => {
|
|||
});
|
||||
|
||||
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 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', () => {
|
||||
const map = generatePointCrawlMap(123);
|
||||
const map = generatePointCrawlMap(createRNG(123), encounters);
|
||||
const settlementLayerIndices = [3, 6];
|
||||
|
||||
for (const layerIdx of settlementLayerIndices) {
|
||||
|
|
@ -77,7 +79,7 @@ describe('generatePointCrawlMap', () => {
|
|||
});
|
||||
|
||||
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 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', () => {
|
||||
const map = generatePointCrawlMap(42);
|
||||
const map = generatePointCrawlMap(createRNG(42), encounters);
|
||||
const wildToWildTransitions = [
|
||||
{ src: 1, tgt: 2 },
|
||||
{ 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', () => {
|
||||
const map = generatePointCrawlMap(42);
|
||||
const map = generatePointCrawlMap(createRNG(42), encounters);
|
||||
const wildToSettlementTransitions = [
|
||||
{ src: 2, tgt: 3 },
|
||||
{ src: 5, tgt: 6 },
|
||||
|
|
@ -125,7 +127,7 @@ describe('generatePointCrawlMap', () => {
|
|||
});
|
||||
|
||||
it('should have settlement nodes connect correctly (1-2-2-1 pattern)', () => {
|
||||
const map = generatePointCrawlMap(42);
|
||||
const map = generatePointCrawlMap(createRNG(42), encounters);
|
||||
const settlementToWildTransitions = [
|
||||
{ src: 3, tgt: 4 },
|
||||
{ src: 6, tgt: 7 },
|
||||
|
|
@ -153,7 +155,7 @@ describe('generatePointCrawlMap', () => {
|
|||
});
|
||||
|
||||
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 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', () => {
|
||||
const map = generatePointCrawlMap(123);
|
||||
const map = generatePointCrawlMap(createRNG(123), encounters);
|
||||
const startId = 'node-0-0';
|
||||
const endId = 'node-9-0';
|
||||
|
||||
|
|
@ -176,7 +178,7 @@ describe('generatePointCrawlMap', () => {
|
|||
});
|
||||
|
||||
it('should not have crossing edges in wild→wild transitions', () => {
|
||||
const map = generatePointCrawlMap(12345);
|
||||
const map = generatePointCrawlMap(createRNG(12345), encounters);
|
||||
const wildToWildTransitions = [
|
||||
{ src: 1, tgt: 2 },
|
||||
{ src: 4, tgt: 5 },
|
||||
|
|
@ -214,7 +216,7 @@ describe('generatePointCrawlMap', () => {
|
|||
});
|
||||
|
||||
it('should not have crossing edges in wild→settlement transitions', () => {
|
||||
const map = generatePointCrawlMap(12345);
|
||||
const map = generatePointCrawlMap(createRNG(12345), encounters);
|
||||
const wildToSettlementTransitions = [
|
||||
{ src: 2, tgt: 3 },
|
||||
{ src: 5, tgt: 6 },
|
||||
|
|
@ -251,7 +253,7 @@ describe('generatePointCrawlMap', () => {
|
|||
});
|
||||
|
||||
it('should not have crossing edges in settlement→wild transitions', () => {
|
||||
const map = generatePointCrawlMap(12345);
|
||||
const map = generatePointCrawlMap(createRNG(12345), encounters);
|
||||
const settlementToWildTransitions = [
|
||||
{ src: 3, tgt: 4 },
|
||||
{ src: 6, tgt: 7 },
|
||||
|
|
@ -288,7 +290,7 @@ describe('generatePointCrawlMap', () => {
|
|||
});
|
||||
|
||||
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()) {
|
||||
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', () => {
|
||||
// Test multiple seeds to ensure no random failure
|
||||
for (let seed = 0; seed < 20; seed++) {
|
||||
const map = generatePointCrawlMap(seed);
|
||||
const map = generatePointCrawlMap(createRNG(seed), encounters);
|
||||
|
||||
for (const node of map.nodes.values()) {
|
||||
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', () => {
|
||||
// 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 = [
|
||||
[1, 2],
|
||||
[4, 5],
|
||||
|
|
@ -351,7 +353,7 @@ describe('generatePointCrawlMap', () => {
|
|||
|
||||
it('should minimize adjacent repetitions in wild→wild connections', () => {
|
||||
// Test that wild nodes connected by wild→wild edges have different types
|
||||
const map = generatePointCrawlMap(12345);
|
||||
const map = generatePointCrawlMap(createRNG(12345), encounters);
|
||||
const wildToWildPairs = [
|
||||
{ src: 1, tgt: 2 },
|
||||
{ src: 4, tgt: 5 },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 {
|
||||
checkCollision,
|
||||
checkBoardCollision,
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
transformShape,
|
||||
getOccupiedCells,
|
||||
IDENTITY_TRANSFORM,
|
||||
} from '@/samples/slay-the-spire-like/utils/shape-collision';
|
||||
} from '@/samples/slay-the-spire-like/system/utils/shape-collision';
|
||||
|
||||
describe('parseShapeString', () => {
|
||||
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