Compare commits
3 Commits
88d31430a6
...
52b6cecd64
| Author | SHA1 | Date |
|---|---|---|
|
|
52b6cecd64 | |
|
|
f336a989e2 | |
|
|
423cc7c841 |
|
|
@ -38,6 +38,8 @@ export type {
|
||||||
InventoryItem,
|
InventoryItem,
|
||||||
MutationResult,
|
MutationResult,
|
||||||
PlacementResult,
|
PlacementResult,
|
||||||
|
GameItem,
|
||||||
|
GameItemMeta,
|
||||||
} from "./system/grid-inventory";
|
} from "./system/grid-inventory";
|
||||||
export {
|
export {
|
||||||
createGridInventory,
|
createGridInventory,
|
||||||
|
|
@ -69,43 +71,8 @@ export {
|
||||||
} from "./system/map";
|
} from "./system/map";
|
||||||
|
|
||||||
// Progress / Run
|
// Progress / Run
|
||||||
export type {
|
export type { EncounterState, RunState } from "./system/encounter";
|
||||||
EncounterResult,
|
export { buildCombatState } from "./system/encounter";
|
||||||
EncounterState,
|
|
||||||
GameItem,
|
|
||||||
GameItemMeta,
|
|
||||||
PlayerState,
|
|
||||||
RunMutationResult,
|
|
||||||
RunState,
|
|
||||||
} from "./system/progress";
|
|
||||||
export {
|
|
||||||
assignEncounterToNode,
|
|
||||||
assignEncountersFromPool,
|
|
||||||
assignAllEncounters,
|
|
||||||
buildCombatState,
|
|
||||||
createEnemyEntities,
|
|
||||||
getCurrentEncounterData,
|
|
||||||
isCombatEncounter,
|
|
||||||
startEncounter,
|
|
||||||
resolveCombatEncounter,
|
|
||||||
createRunState,
|
|
||||||
canMoveTo,
|
|
||||||
moveToNode,
|
|
||||||
resolveEncounter,
|
|
||||||
isEncounterResolved,
|
|
||||||
damagePlayer,
|
|
||||||
healPlayer,
|
|
||||||
setMaxHp,
|
|
||||||
addGold,
|
|
||||||
spendGold,
|
|
||||||
addItem,
|
|
||||||
removeItem,
|
|
||||||
getCurrentNode,
|
|
||||||
getReachableChildren,
|
|
||||||
getUnresolvedChildren,
|
|
||||||
isAtStartNode,
|
|
||||||
isAtEndNode,
|
|
||||||
} from "./system/progress";
|
|
||||||
|
|
||||||
// Combat
|
// Combat
|
||||||
export type {
|
export type {
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ import { promptMainAction } from "@/samples/slay-the-spire-like/system/combat/pr
|
||||||
import { moveToRegion, shuffle } from "@/core/region";
|
import { moveToRegion, shuffle } from "@/core/region";
|
||||||
import { createMiddlewareChain } from "@/utils/middleware";
|
import { createMiddlewareChain } from "@/utils/middleware";
|
||||||
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
|
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 = {
|
type TriggerTypes = {
|
||||||
onCombatStart: {};
|
onCombatStart: {};
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,71 @@
|
||||||
import {moveToRegion } from '@/core/region';
|
import { moveToRegion } from "@/core/region";
|
||||||
import { createRegion } from '@/core/region';
|
import { createRegion } from "@/core/region";
|
||||||
import type { GridInventory } from '../grid-inventory/types';
|
import type { GameItemMeta, GridInventory } from "../grid-inventory/types";
|
||||||
import type { GameItemMeta } from '../progress/types';
|
import type { CardData } from "../types";
|
||||||
import type { CardData } from '../types';
|
import type { DeckRegions, GameCard, PlayerDeck } from "./types";
|
||||||
import type {DeckRegions, GameCard, PlayerDeck} from './types';
|
|
||||||
|
|
||||||
function generateCardId(itemId: string, cellIndex: number): string {
|
function generateCardId(itemId: string, cellIndex: number): string {
|
||||||
return `card-${itemId}-${cellIndex}`;
|
return `card-${itemId}-${cellIndex}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCard( itemId: string, cardData: CardData, cellIndex: number): GameCard {
|
function createCard(
|
||||||
return {
|
itemId: string,
|
||||||
id: generateCardId(itemId, cellIndex),
|
cardData: CardData,
|
||||||
regionId: '',
|
cellIndex: number,
|
||||||
position: [],
|
): GameCard {
|
||||||
itemId,
|
return {
|
||||||
cardData
|
id: generateCardId(itemId, cellIndex),
|
||||||
};
|
regionId: "",
|
||||||
|
position: [],
|
||||||
|
itemId,
|
||||||
|
cardData,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDeckRegions(): DeckRegions {
|
function createDeckRegions(): DeckRegions {
|
||||||
return {
|
return {
|
||||||
drawPile: createRegion('drawPile', []),
|
drawPile: createRegion("drawPile", []),
|
||||||
hand: createRegion('hand', []),
|
hand: createRegion("hand", []),
|
||||||
discardPile: createRegion('discardPile', []),
|
discardPile: createRegion("discardPile", []),
|
||||||
exhaustPile: createRegion('exhaustPile', []),
|
exhaustPile: createRegion("exhaustPile", []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateDeckFromInventory(inventory: GridInventory<GameItemMeta>): PlayerDeck {
|
function generateDeckFromInventory(
|
||||||
const cards: Record<string, GameCard> = {};
|
inventory: GridInventory<GameItemMeta>,
|
||||||
const regions = createDeckRegions();
|
): PlayerDeck {
|
||||||
|
const cards: Record<string, GameCard> = {};
|
||||||
|
const regions = createDeckRegions();
|
||||||
|
|
||||||
for (const item of inventory.items.values()) {
|
for (const item of inventory.items.values()) {
|
||||||
const itemData = item.meta?.itemData;
|
const itemData = item.meta?.itemData;
|
||||||
if (!itemData) continue;
|
if (!itemData) continue;
|
||||||
|
|
||||||
const count = item.shape.count;
|
const count = item.shape.count;
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const card = createCard(item.id, itemData.card, i);
|
const card = createCard(item.id, itemData.card, i);
|
||||||
cards[card.id] = card;
|
cards[card.id] = card;
|
||||||
moveToRegion(card, null, regions.drawPile);
|
moveToRegion(card, null, regions.drawPile);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cards,
|
cards,
|
||||||
regions
|
regions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPlayerDeck(): PlayerDeck {
|
function createPlayerDeck(): PlayerDeck {
|
||||||
return {
|
return {
|
||||||
cards: {},
|
cards: {},
|
||||||
regions: createDeckRegions(),
|
regions: createDeckRegions(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
generateDeckFromInventory,
|
generateDeckFromInventory,
|
||||||
createCard,
|
createCard,
|
||||||
createPlayerDeck,
|
createPlayerDeck,
|
||||||
createDeckRegions,
|
createDeckRegions,
|
||||||
generateCardId,
|
generateCardId,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import {
|
||||||
|
CombatState,
|
||||||
|
EffectTable,
|
||||||
|
EnemyEntity,
|
||||||
|
PlayerEntity,
|
||||||
|
} from "../combat/types";
|
||||||
|
import { generateDeckFromInventory } from "../deck";
|
||||||
|
import { GridInventory } from "../grid-inventory";
|
||||||
|
import { GameItemMeta } from "../grid-inventory/types";
|
||||||
|
import { EffectData, EncounterData, EnemyData, IntentData } from "../types";
|
||||||
|
import { CombatEncounterState, RunState } from "./types";
|
||||||
|
|
||||||
|
export function buildCombatState(
|
||||||
|
runState: RunState,
|
||||||
|
inventory: GridInventory<GameItemMeta>,
|
||||||
|
encounter: CombatEncounterState,
|
||||||
|
): CombatState {
|
||||||
|
const deck = generateDeckFromInventory(inventory);
|
||||||
|
const player = createPlayerEntity(runState, deck);
|
||||||
|
const enemies = createEnemyEntities(encounter.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enemies,
|
||||||
|
player,
|
||||||
|
phase: "playerTurn",
|
||||||
|
turnNumber: 1,
|
||||||
|
result: null,
|
||||||
|
loot: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEnemyEntities(encounter: EncounterData): EnemyEntity[] {
|
||||||
|
const enemies: EnemyEntity[] = [];
|
||||||
|
let instanceCounter = 0;
|
||||||
|
|
||||||
|
for (const [enemyData, hp, encounterBuffs] of encounter.enemies) {
|
||||||
|
const instanceId = `${enemyData.id}-${instanceCounter++}`;
|
||||||
|
const intents = buildIntentMap(enemyData);
|
||||||
|
const initialIntent = findInitialIntent(enemyData);
|
||||||
|
const effects = buildEffectTable(encounterBuffs);
|
||||||
|
|
||||||
|
const entity: EnemyEntity = {
|
||||||
|
id: instanceId,
|
||||||
|
enemy: enemyData,
|
||||||
|
hp,
|
||||||
|
maxHp: hp,
|
||||||
|
isAlive: true,
|
||||||
|
effects,
|
||||||
|
intents,
|
||||||
|
currentIntent: initialIntent,
|
||||||
|
};
|
||||||
|
enemies.push(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return enemies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a map of intent ID -> IntentData for an enemy.
|
||||||
|
*/
|
||||||
|
function buildIntentMap(enemy: EnemyData): Record<string, IntentData> {
|
||||||
|
const intents: Record<string, IntentData> = {};
|
||||||
|
for (const intent of enemy.intents) {
|
||||||
|
intents[intent.id] = intent;
|
||||||
|
}
|
||||||
|
return intents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the initial intent ID for an enemy.
|
||||||
|
*/
|
||||||
|
function findInitialIntent(enemy: EnemyData): IntentData {
|
||||||
|
for (const intent of enemy.intents) {
|
||||||
|
if (intent.initialIntent) {
|
||||||
|
return intent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (enemy.intents.length === 0) {
|
||||||
|
throw new Error(`Enemy "${enemy.id}" has no intents`);
|
||||||
|
}
|
||||||
|
return enemy.intents[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEffectTable(buffs: readonly [EffectData, number][]): EffectTable {
|
||||||
|
const table: EffectTable = {};
|
||||||
|
for (const [effect, stacks] of buffs) {
|
||||||
|
table[effect.id] = { data: effect, stacks };
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlayerEntity(
|
||||||
|
runState: RunState,
|
||||||
|
deck: ReturnType<typeof generateDeckFromInventory>,
|
||||||
|
): PlayerEntity {
|
||||||
|
return {
|
||||||
|
id: "player",
|
||||||
|
hp: runState.currentHp,
|
||||||
|
maxHp: runState.maxHp,
|
||||||
|
isAlive: runState.currentHp > 0,
|
||||||
|
energy: 3,
|
||||||
|
maxEnergy: 3,
|
||||||
|
deck,
|
||||||
|
itemEffects: {},
|
||||||
|
effects: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { RunState, EncounterState } from "./types";
|
||||||
|
export { buildCombatState } from "./combat";
|
||||||
|
export { generateInstanceId } from "./shop";
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { RunState } from "./types";
|
||||||
|
|
||||||
|
const DEFAULT_MAX_HP = 50;
|
||||||
|
const DEFAULT_GOLD = 50;
|
||||||
|
|
||||||
|
export function createRunState(startNode: string): RunState {
|
||||||
|
return {
|
||||||
|
maxHp: DEFAULT_MAX_HP,
|
||||||
|
currentHp: DEFAULT_MAX_HP,
|
||||||
|
gold: DEFAULT_GOLD,
|
||||||
|
_idCounter: { value: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function damagePlayer(runState: RunState, amount: number): void {
|
||||||
|
runState.currentHp = Math.max(0, runState.currentHp - amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function healPlayer(runState: RunState, amount: number): void {
|
||||||
|
runState.currentHp = Math.min(runState.maxHp, runState.currentHp + amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMaxHp(runState: RunState, newMax: number): void {
|
||||||
|
const diff = Math.max(0, newMax - runState.maxHp);
|
||||||
|
runState.maxHp = newMax;
|
||||||
|
runState.currentHp = Math.max(0, Math.min(newMax, runState.currentHp + diff));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds gold to the player.
|
||||||
|
*/
|
||||||
|
export function addGold(runState: RunState, amount: number): void {
|
||||||
|
runState.gold += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spends gold. Returns false if the player doesn't have enough gold.
|
||||||
|
*/
|
||||||
|
export function spendGold(
|
||||||
|
runState: RunState,
|
||||||
|
amount: number,
|
||||||
|
): { success: true } | { success: false; reason: string } {
|
||||||
|
if (amount <= 0) {
|
||||||
|
return { success: false, reason: "金额必须大于零" };
|
||||||
|
}
|
||||||
|
if (runState.gold < amount) {
|
||||||
|
return { success: false, reason: "金币不足" };
|
||||||
|
}
|
||||||
|
|
||||||
|
runState.gold -= amount;
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export function generateInstanceId(counter: { value: number }): string {
|
||||||
|
counter.value++;
|
||||||
|
return `item-${counter.value}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { GridInventory } from "../grid-inventory";
|
||||||
|
import { GameItemMeta } from "../grid-inventory/types";
|
||||||
|
import { EncounterData } from "../types";
|
||||||
|
|
||||||
|
export interface RunState {
|
||||||
|
_idCounter: { value: number };
|
||||||
|
|
||||||
|
currentHp: number;
|
||||||
|
maxHp: number;
|
||||||
|
gold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EncounterState =
|
||||||
|
| CombatEncounterState
|
||||||
|
| ShopEncounterState
|
||||||
|
| CurioEncounterState
|
||||||
|
| CampEncounterState
|
||||||
|
| DialogueEncounterState;
|
||||||
|
|
||||||
|
export type CombatEncounterState = {
|
||||||
|
data: EncounterData<"minion" | "elite">;
|
||||||
|
blocked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShopEncounterState = {
|
||||||
|
data: EncounterData<"shop">;
|
||||||
|
items: GridInventory<GameItemMeta & { sellPrice: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CurioEncounterState = {
|
||||||
|
data: EncounterData<"curio">;
|
||||||
|
items: GridInventory<GameItemMeta>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampEncounterState = {
|
||||||
|
data: EncounterData<"camp">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DialogueEncounterState = {
|
||||||
|
data: EncounterData<"event">;
|
||||||
|
blocked: boolean;
|
||||||
|
};
|
||||||
|
|
@ -1,13 +1,22 @@
|
||||||
export type { CellCoordinate, CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './types';
|
export type {
|
||||||
|
CellCoordinate,
|
||||||
|
CellKey,
|
||||||
|
GridInventory,
|
||||||
|
InventoryItem,
|
||||||
|
MutationResult,
|
||||||
|
PlacementResult,
|
||||||
|
} from "./types";
|
||||||
export {
|
export {
|
||||||
createGridInventory,
|
createGridInventory,
|
||||||
flipItem,
|
flipItem,
|
||||||
getAdjacentItems,
|
getAdjacentItems,
|
||||||
getItemAtCell,
|
getItemAtCell,
|
||||||
getOccupiedCellSet,
|
getOccupiedCellSet,
|
||||||
moveItem,
|
moveItem,
|
||||||
placeItem,
|
placeItem,
|
||||||
removeItem,
|
removeItem,
|
||||||
rotateItem,
|
rotateItem,
|
||||||
validatePlacement,
|
validatePlacement,
|
||||||
} from './transform';
|
} from "./transform";
|
||||||
|
|
||||||
|
export type { GameItemMeta, GameItem } from "./types";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { ParsedShape } from '../utils/parse-shape';
|
import { ItemData } from "../types";
|
||||||
import type { Transform2D } from '../utils/shape-collision';
|
import type { ParsedShape } from "../utils/parse-shape";
|
||||||
|
import type { Transform2D } from "../utils/shape-collision";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* String key representing a grid cell in "x,y" format.
|
* String key representing a grid cell in "x,y" format.
|
||||||
|
|
@ -10,8 +11,8 @@ export type CellKey = `${number},${number}`;
|
||||||
* Simple 2D coordinate for grid cells.
|
* Simple 2D coordinate for grid cells.
|
||||||
*/
|
*/
|
||||||
export interface CellCoordinate {
|
export interface CellCoordinate {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -19,25 +20,29 @@ export interface CellCoordinate {
|
||||||
* @template TMeta - Optional metadata type for game-specific data
|
* @template TMeta - Optional metadata type for game-specific data
|
||||||
*/
|
*/
|
||||||
export interface InventoryItem<TMeta> {
|
export interface InventoryItem<TMeta> {
|
||||||
/** Unique item identifier */
|
/** Unique item identifier */
|
||||||
id: string;
|
id: string;
|
||||||
/** Reference to the item's shape definition */
|
/** Reference to the item's shape definition */
|
||||||
shape: ParsedShape;
|
shape: ParsedShape;
|
||||||
/** Current transformation (position, rotation, flips) */
|
/** Current transformation (position, rotation, flips) */
|
||||||
transform: Transform2D;
|
transform: Transform2D;
|
||||||
/** Optional metadata for game-specific data */
|
/** Optional metadata for game-specific data */
|
||||||
meta?: TMeta;
|
meta?: TMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of a placement validation check.
|
* Result of a placement validation check.
|
||||||
*/
|
*/
|
||||||
export type PlacementResult = { valid: true } | { valid: false; reason: string };
|
export type PlacementResult =
|
||||||
|
| { valid: true }
|
||||||
|
| { valid: false; reason: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of a mutation operation (move, rotate, flip).
|
* Result of a mutation operation (move, rotate, flip).
|
||||||
*/
|
*/
|
||||||
export type MutationResult = { success: true } | { success: false; reason: string };
|
export type MutationResult =
|
||||||
|
| { success: true }
|
||||||
|
| { success: false; reason: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grid inventory state.
|
* Grid inventory state.
|
||||||
|
|
@ -45,12 +50,21 @@ export type MutationResult = { success: true } | { success: false; reason: strin
|
||||||
* @template TMeta - Optional metadata type for items
|
* @template TMeta - Optional metadata type for items
|
||||||
*/
|
*/
|
||||||
export interface GridInventory<TMeta> {
|
export interface GridInventory<TMeta> {
|
||||||
/** Board width in cells */
|
/** Board width in cells */
|
||||||
width: number;
|
width: number;
|
||||||
/** Board height in cells */
|
/** Board height in cells */
|
||||||
height: number;
|
height: number;
|
||||||
/** Map of itemId -> InventoryItem for all placed items */
|
/** Map of itemId -> InventoryItem for all placed items */
|
||||||
items: Map<string, InventoryItem<TMeta>>;
|
items: Map<string, InventoryItem<TMeta>>;
|
||||||
/** Set of occupied cells in "x,y" format for O(1) collision lookups */
|
/** Set of occupied cells in "x,y" format for O(1) collision lookups */
|
||||||
occupiedCells: Set<CellKey>;
|
occupiedCells: Set<CellKey>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GameItemMeta {
|
||||||
|
itemData: ItemData;
|
||||||
|
shape: ParsedShape;
|
||||||
|
consumedUses?: number;
|
||||||
|
startEffects?: Record<string, number>;
|
||||||
|
tradePrice?: number;
|
||||||
|
}
|
||||||
|
export type GameItem = InventoryItem<GameItemMeta>;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,24 @@
|
||||||
export { MapNodeType, MapLayerType } from './types';
|
export { MapNodeType, MapLayerType } from "./types";
|
||||||
export type { MapNode, MapLayer, PointCrawlMap, MapGenerationConfig } from './types';
|
export type {
|
||||||
|
MapNode,
|
||||||
|
MapLayer,
|
||||||
|
PointCrawlMap,
|
||||||
|
MapGenerationConfig,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export { generatePointCrawlMap } from './generator';
|
export { generatePointCrawlMap } from "./generator";
|
||||||
export { getNode, getChildren, getParents, hasPath, findAllPaths } from './generator';
|
export {
|
||||||
|
getNode,
|
||||||
|
getChildren,
|
||||||
|
getParents,
|
||||||
|
hasPath,
|
||||||
|
findAllPaths,
|
||||||
|
} from "./generator";
|
||||||
|
|
||||||
|
export {
|
||||||
|
canMoveTo,
|
||||||
|
moveToNode,
|
||||||
|
getReachableChildren,
|
||||||
|
isAtEndNode,
|
||||||
|
isAtStartNode,
|
||||||
|
} from "./navigation";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { getNode } from "./generator";
|
||||||
|
import { PointCrawlMap, PointCrawlMapNavigator } from "./types";
|
||||||
|
|
||||||
|
export function canMoveTo(
|
||||||
|
navigator: PointCrawlMapNavigator,
|
||||||
|
map: PointCrawlMap,
|
||||||
|
targetNodeId: string,
|
||||||
|
): boolean {
|
||||||
|
const currentNode = getNode(map, navigator.currentNodeId);
|
||||||
|
if (!currentNode) return false;
|
||||||
|
|
||||||
|
return currentNode.childIds.includes(targetNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveToNode(
|
||||||
|
navigator: PointCrawlMapNavigator,
|
||||||
|
map: PointCrawlMap,
|
||||||
|
targetNodeId: string,
|
||||||
|
): boolean {
|
||||||
|
if (!canMoveTo(navigator, map, targetNodeId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetNode = getNode(map, targetNodeId);
|
||||||
|
if (!targetNode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current position
|
||||||
|
navigator.currentNodeId = targetNodeId;
|
||||||
|
navigator.visitedNodes.add(targetNodeId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* getReachableChildren(
|
||||||
|
map: PointCrawlMap,
|
||||||
|
navigator: PointCrawlMapNavigator,
|
||||||
|
) {
|
||||||
|
const currentNode = getNode(map, navigator.currentNodeId);
|
||||||
|
if (!currentNode) return;
|
||||||
|
|
||||||
|
for (const id of currentNode.childIds) {
|
||||||
|
const node = getNode(map, id);
|
||||||
|
if (!node) continue;
|
||||||
|
yield node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAtStartNode(
|
||||||
|
map: PointCrawlMap,
|
||||||
|
navigator: PointCrawlMapNavigator,
|
||||||
|
): boolean {
|
||||||
|
return navigator.currentNodeId === map.layers[0]?.nodes[0]?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the current encounter is the end node.
|
||||||
|
*/
|
||||||
|
export function isAtEndNode(
|
||||||
|
map: PointCrawlMap,
|
||||||
|
navigator: PointCrawlMapNavigator,
|
||||||
|
): boolean {
|
||||||
|
const endLayer = map.layers[map.layers.length - 1];
|
||||||
|
return endLayer?.nodes[0]?.id === navigator.currentNodeId;
|
||||||
|
}
|
||||||
|
|
@ -1,83 +1,88 @@
|
||||||
import {EncounterData} from "@/samples/slay-the-spire-like/system/types";
|
import { EncounterData } from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Types of nodes that can appear on the point crawl map.
|
* Types of nodes that can appear on the point crawl map.
|
||||||
*/
|
*/
|
||||||
export enum MapNodeType {
|
export enum MapNodeType {
|
||||||
Start = 'start',
|
Start = "start",
|
||||||
End = 'end',
|
End = "end",
|
||||||
Minion = 'minion',
|
Minion = "minion",
|
||||||
Elite = 'elite',
|
Elite = "elite",
|
||||||
Event = 'event',
|
Event = "event",
|
||||||
Camp = 'camp',
|
Camp = "camp",
|
||||||
Shop = 'shop',
|
Shop = "shop",
|
||||||
Curio = 'curio',
|
Curio = "curio",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Semantic type of a layer.
|
* Semantic type of a layer.
|
||||||
*/
|
*/
|
||||||
export enum MapLayerType {
|
export enum MapLayerType {
|
||||||
Wild = 'wild',
|
Wild = "wild",
|
||||||
Settlement = 'settlement',
|
Settlement = "settlement",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single node on the map.
|
* A single node on the map.
|
||||||
*/
|
*/
|
||||||
export interface MapNode {
|
export interface MapNode {
|
||||||
/** Unique identifier */
|
/** Unique identifier */
|
||||||
id: string;
|
id: string;
|
||||||
/** Which layer this node belongs to */
|
/** Which layer this node belongs to */
|
||||||
layerIndex: number;
|
layerIndex: number;
|
||||||
/** Semantic type of the node */
|
/** Semantic type of the node */
|
||||||
type: MapNodeType;
|
type: MapNodeType;
|
||||||
/** IDs of nodes in the next layer this node connects to */
|
/** IDs of nodes in the next layer this node connects to */
|
||||||
childIds: string[];
|
childIds: string[];
|
||||||
/** Encounter data assigned to this node (from encounter CSV) */
|
/** Encounter data assigned to this node (from encounter CSV) */
|
||||||
encounter?: EncounterData;
|
encounter?: EncounterData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A horizontal layer of nodes at the same progression stage.
|
* A horizontal layer of nodes at the same progression stage.
|
||||||
*/
|
*/
|
||||||
export interface MapLayer {
|
export interface MapLayer {
|
||||||
/** Layer index (0 = start, last = end) */
|
/** Layer index (0 = start, last = end) */
|
||||||
index: number;
|
index: number;
|
||||||
/** Ordered IDs of nodes in this layer */
|
/** Ordered IDs of nodes in this layer */
|
||||||
nodeIds: string[];
|
nodeIds: string[];
|
||||||
/** Semantic type of the layer */
|
/** Semantic type of the layer */
|
||||||
layerType: MapLayerType | 'start' | 'end';
|
layerType: MapLayerType | "start" | "end";
|
||||||
/** Direct references to nodes in this layer (for performance) */
|
/** Direct references to nodes in this layer (for performance) */
|
||||||
nodes: MapNode[];
|
nodes: MapNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A fully generated point crawl map.
|
* A fully generated point crawl map.
|
||||||
*/
|
*/
|
||||||
export interface PointCrawlMap {
|
export interface PointCrawlMap {
|
||||||
/** Layers from start to end */
|
/** Layers from start to end */
|
||||||
layers: MapLayer[];
|
layers: MapLayer[];
|
||||||
/** All nodes keyed by ID */
|
/** All nodes keyed by ID */
|
||||||
nodes: Map<string, MapNode>;
|
nodes: Map<string, MapNode>;
|
||||||
/** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */
|
/** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */
|
||||||
parentIndex?: Map<string, string[]>;
|
parentIndex?: Map<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointCrawlMapNavigator {
|
||||||
|
currentNodeId: string;
|
||||||
|
visitedNodes: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for map generation.
|
* Configuration for map generation.
|
||||||
*/
|
*/
|
||||||
export interface MapGenerationConfig {
|
export interface MapGenerationConfig {
|
||||||
/** Total number of layers (including start and end) */
|
/** Total number of layers (including start and end) */
|
||||||
totalLayers: number;
|
totalLayers: number;
|
||||||
/** Number of nodes in each wild layer */
|
/** Number of nodes in each wild layer */
|
||||||
wildLayerNodeCount: number;
|
wildLayerNodeCount: number;
|
||||||
/** Number of nodes in each settlement layer */
|
/** Number of nodes in each settlement layer */
|
||||||
settlementLayerNodeCount: number;
|
settlementLayerNodeCount: number;
|
||||||
/** Probability weights for wild node types (should sum to 100) */
|
/** Probability weights for wild node types (should sum to 100) */
|
||||||
wildNodeTypeWeights: {
|
wildNodeTypeWeights: {
|
||||||
[MapNodeType.Minion]: number;
|
[MapNodeType.Minion]: number;
|
||||||
[MapNodeType.Elite]: number;
|
[MapNodeType.Elite]: number;
|
||||||
[MapNodeType.Event]: number;
|
[MapNodeType.Event]: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
import type { PointCrawlMap } from "../map/types";
|
|
||||||
import type {
|
|
||||||
CombatState,
|
|
||||||
EnemyEntity,
|
|
||||||
PlayerEntity,
|
|
||||||
EffectTable,
|
|
||||||
} from "../combat/types";
|
|
||||||
import type {
|
|
||||||
EncounterData,
|
|
||||||
EnemyData,
|
|
||||||
EffectData,
|
|
||||||
IntentData,
|
|
||||||
} from "../types";
|
|
||||||
import type { RunState } from "./types";
|
|
||||||
import { generateDeckFromInventory } from "../deck/factory";
|
|
||||||
import { ReadonlyRNG } from "@/utils/rng";
|
|
||||||
|
|
||||||
// -- Encounter assignment to nodes --
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assigns an encounter from a pool to a specific node.
|
|
||||||
* Replaces any existing encounter on that node.
|
|
||||||
*/
|
|
||||||
export function assignEncounterToNode(
|
|
||||||
map: PointCrawlMap,
|
|
||||||
nodeId: string,
|
|
||||||
encounter: EncounterData,
|
|
||||||
): void {
|
|
||||||
const node = map.nodes.get(nodeId);
|
|
||||||
if (!node) {
|
|
||||||
throw new Error(`Node "${nodeId}" not found`);
|
|
||||||
}
|
|
||||||
node.encounter = encounter;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assigns encounters from a typed pool to all unassigned nodes of matching type.
|
|
||||||
* Uses RNG for random selection; each encounter can be assigned multiple times.
|
|
||||||
*/
|
|
||||||
export function assignEncountersFromPool(
|
|
||||||
map: PointCrawlMap,
|
|
||||||
encounterPool: EncounterData[],
|
|
||||||
rng: ReadonlyRNG,
|
|
||||||
): void {
|
|
||||||
if (encounterPool.length === 0) return;
|
|
||||||
|
|
||||||
for (const node of map.nodes.values()) {
|
|
||||||
if (node.type === "start" || node.type === "end") continue;
|
|
||||||
if (node.encounter) continue;
|
|
||||||
|
|
||||||
const assigned = encounterPool[rng.nextInt(encounterPool.length)];
|
|
||||||
node.encounter = assigned;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch-assigns encounters for all node types from a typed index.
|
|
||||||
* Keys in the index should match encounter type strings (e.g. 'minion', 'elite').
|
|
||||||
*/
|
|
||||||
export function assignAllEncounters(
|
|
||||||
map: PointCrawlMap,
|
|
||||||
encounterIndex: Map<string, EncounterData[]>,
|
|
||||||
rng: ReadonlyRNG,
|
|
||||||
): void {
|
|
||||||
for (const node of map.nodes.values()) {
|
|
||||||
if (node.type === "start" || node.type === "end") continue;
|
|
||||||
if (node.encounter) continue;
|
|
||||||
|
|
||||||
const encounterType = node.type;
|
|
||||||
const pool = encounterIndex.get(encounterType);
|
|
||||||
if (!pool || pool.length === 0) continue;
|
|
||||||
|
|
||||||
node.encounter = pool[rng.nextInt(pool.length)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- CombatState construction --
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a full CombatState from an encounter and the current run state.
|
|
||||||
* - Creates EnemyEntity instances with HP, initial buffs, and intents
|
|
||||||
* - Creates PlayerEntity with energy (3), deck from inventory, and HP from run state
|
|
||||||
* - Sets initial phase to 'playerTurn', turn 1
|
|
||||||
*/
|
|
||||||
export function buildCombatState(runState: RunState): CombatState {
|
|
||||||
const encounter = getCurrentEncounterData(runState);
|
|
||||||
if (!encounter)
|
|
||||||
throw new Error(`No encounter found for node ${runState.currentNodeId}`);
|
|
||||||
|
|
||||||
const enemies = createEnemyEntities(encounter);
|
|
||||||
const deck = generateDeckFromInventory(runState.inventory);
|
|
||||||
const player = createPlayerEntity(runState, deck);
|
|
||||||
|
|
||||||
return {
|
|
||||||
enemies,
|
|
||||||
player,
|
|
||||||
phase: "playerTurn",
|
|
||||||
turnNumber: 1,
|
|
||||||
result: null,
|
|
||||||
loot: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates EnemyEntity instances from encounter enemy definitions.
|
|
||||||
* Each enemy gets: HP from encounter tuple, initial buffs from encounter, intents from enemy definition.
|
|
||||||
*/
|
|
||||||
export function createEnemyEntities(encounter: EncounterData): EnemyEntity[] {
|
|
||||||
const enemies: EnemyEntity[] = [];
|
|
||||||
let instanceCounter = 0;
|
|
||||||
|
|
||||||
for (const [enemyData, hp, encounterBuffs] of encounter.enemies) {
|
|
||||||
const instanceId = `${enemyData.id}-${instanceCounter++}`;
|
|
||||||
const intents = buildIntentMap(enemyData);
|
|
||||||
const initialIntent = findInitialIntent(enemyData);
|
|
||||||
const effects = buildEffectTable(encounterBuffs);
|
|
||||||
|
|
||||||
const entity: EnemyEntity = {
|
|
||||||
id: instanceId,
|
|
||||||
enemy: enemyData,
|
|
||||||
hp,
|
|
||||||
maxHp: hp,
|
|
||||||
isAlive: true,
|
|
||||||
effects,
|
|
||||||
intents,
|
|
||||||
currentIntent: initialIntent,
|
|
||||||
};
|
|
||||||
enemies.push(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
return enemies;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a map of intent ID -> IntentData for an enemy.
|
|
||||||
*/
|
|
||||||
function buildIntentMap(enemy: EnemyData): Record<string, IntentData> {
|
|
||||||
const intents: Record<string, IntentData> = {};
|
|
||||||
for (const intent of enemy.intents) {
|
|
||||||
intents[intent.id] = intent;
|
|
||||||
}
|
|
||||||
return intents;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds the initial intent ID for an enemy.
|
|
||||||
*/
|
|
||||||
function findInitialIntent(enemy: EnemyData): IntentData {
|
|
||||||
for (const intent of enemy.intents) {
|
|
||||||
if (intent.initialIntent) {
|
|
||||||
return intent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (enemy.intents.length === 0) {
|
|
||||||
throw new Error(`Enemy "${enemy.id}" has no intents`);
|
|
||||||
}
|
|
||||||
return enemy.intents[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds an EffectTable from encounter buff definitions.
|
|
||||||
*/
|
|
||||||
function buildEffectTable(buffs: readonly [EffectData, number][]): EffectTable {
|
|
||||||
const table: EffectTable = {};
|
|
||||||
for (const [effect, stacks] of buffs) {
|
|
||||||
table[effect.id] = { data: effect, stacks };
|
|
||||||
}
|
|
||||||
return table;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a PlayerEntity from the run state and deck.
|
|
||||||
*/
|
|
||||||
function createPlayerEntity(
|
|
||||||
runState: RunState,
|
|
||||||
deck: ReturnType<typeof generateDeckFromInventory>,
|
|
||||||
): PlayerEntity {
|
|
||||||
return {
|
|
||||||
id: "player",
|
|
||||||
hp: runState.player.currentHp,
|
|
||||||
maxHp: runState.player.maxHp,
|
|
||||||
isAlive: runState.player.currentHp > 0,
|
|
||||||
energy: 3,
|
|
||||||
maxEnergy: 3,
|
|
||||||
deck,
|
|
||||||
itemEffects: {},
|
|
||||||
effects: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Encounter lifecycle --
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the encounter data for the current node.
|
|
||||||
*/
|
|
||||||
export function getCurrentEncounterData(
|
|
||||||
runState: RunState,
|
|
||||||
): EncounterData | undefined {
|
|
||||||
const node = runState.map.nodes.get(runState.currentNodeId);
|
|
||||||
return node?.encounter;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the current node has a combat encounter.
|
|
||||||
*/
|
|
||||||
export function isCombatEncounter(runState: RunState): boolean {
|
|
||||||
const encounter = getCurrentEncounterData(runState);
|
|
||||||
return encounter !== undefined && encounter.enemies.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the encounter at the current node.
|
|
||||||
* Returns the constructed CombatState, or null if no combat encounter.
|
|
||||||
*/
|
|
||||||
export function startEncounter(runState: RunState): CombatState | null {
|
|
||||||
const encounter = getCurrentEncounterData(runState);
|
|
||||||
if (!encounter || encounter.enemies.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildCombatState(runState);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a completed combat and applies rewards to the run state.
|
|
||||||
* Handles: gold loot, item rewards, HP changes.
|
|
||||||
* Marks the encounter as resolved.
|
|
||||||
*/
|
|
||||||
export function resolveCombatEncounter(
|
|
||||||
runState: RunState,
|
|
||||||
combatState: CombatState,
|
|
||||||
): { success: true } | { success: false; reason: string } {
|
|
||||||
if (runState.currentEncounter.resolved) {
|
|
||||||
return { success: false, reason: "该遭遇已解决" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply HP from combat state back to run state
|
|
||||||
runState.player.currentHp = Math.max(0, combatState.player.hp);
|
|
||||||
|
|
||||||
// Apply loot
|
|
||||||
for (const loot of combatState.loot) {
|
|
||||||
if (loot.type === "gold") {
|
|
||||||
runState.player.gold += loot.amount;
|
|
||||||
}
|
|
||||||
// Item rewards are handled by the caller via addItem()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as resolved
|
|
||||||
runState.currentEncounter.resolved = true;
|
|
||||||
runState.currentEncounter.result = {
|
|
||||||
hpLost: runState.player.maxHp - runState.player.currentHp,
|
|
||||||
};
|
|
||||||
runState.resolvedNodeIds.add(runState.currentNodeId);
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
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 {ItemData} from "@/samples/slay-the-spire-like/system/types";
|
|
||||||
|
|
||||||
// Re-export types
|
|
||||||
export type {
|
|
||||||
EncounterResult,
|
|
||||||
EncounterState,
|
|
||||||
GameItem,
|
|
||||||
GameItemMeta,
|
|
||||||
PlayerState,
|
|
||||||
RunMutationResult,
|
|
||||||
RunState,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
// Re-export encounter construction functions
|
|
||||||
export {
|
|
||||||
assignEncounterToNode,
|
|
||||||
assignEncountersFromPool,
|
|
||||||
assignAllEncounters,
|
|
||||||
buildCombatState,
|
|
||||||
createEnemyEntities,
|
|
||||||
getCurrentEncounterData,
|
|
||||||
isCombatEncounter,
|
|
||||||
startEncounter,
|
|
||||||
resolveCombatEncounter,
|
|
||||||
} from './encounter';
|
|
||||||
|
|
||||||
// -- Constants --
|
|
||||||
|
|
||||||
const INVENTORY_WIDTH = 6;
|
|
||||||
const INVENTORY_HEIGHT = 4;
|
|
||||||
|
|
||||||
const DEFAULT_MAX_HP = 50;
|
|
||||||
const DEFAULT_GOLD = 50;
|
|
||||||
|
|
||||||
// -- Run creation --
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new run state with a generated map, player stats, and starter inventory.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export function createRunState(map: PointCrawlMap, starterItems: ItemData[]): RunState {
|
|
||||||
// Find the start node
|
|
||||||
const startNode = map.layers[0].nodes[0];
|
|
||||||
|
|
||||||
// Create empty inventory
|
|
||||||
const inventory = createGridInventory<GameItemMeta>(INVENTORY_WIDTH, INVENTORY_HEIGHT);
|
|
||||||
const idCounter = { value: 0 };
|
|
||||||
|
|
||||||
// Place starter items
|
|
||||||
for (const itemData of starterItems) {
|
|
||||||
const shape = parseShapeString(itemData.shape);
|
|
||||||
const itemInstance = tryPlaceItemInInventory(inventory, itemData, shape, idCounter);
|
|
||||||
if (!itemInstance) {
|
|
||||||
// Inventory too small; item skipped
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
map,
|
|
||||||
player: {
|
|
||||||
maxHp: DEFAULT_MAX_HP,
|
|
||||||
currentHp: DEFAULT_MAX_HP,
|
|
||||||
gold: DEFAULT_GOLD,
|
|
||||||
},
|
|
||||||
inventory,
|
|
||||||
currentNodeId: startNode.id,
|
|
||||||
currentEncounter: {
|
|
||||||
nodeId: startNode.id,
|
|
||||||
resolved: false,
|
|
||||||
},
|
|
||||||
resolvedNodeIds: new Set<string>(),
|
|
||||||
_idCounter: idCounter,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Movement --
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether the player can move to the target node.
|
|
||||||
* The target must be a child of the current node.
|
|
||||||
*/
|
|
||||||
export function canMoveTo(runState: RunState, targetNodeId: string): boolean {
|
|
||||||
const currentNode = getNode(runState.map, runState.currentNodeId);
|
|
||||||
if (!currentNode) return false;
|
|
||||||
|
|
||||||
return currentNode.childIds.includes(targetNodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves the player to the target node.
|
|
||||||
* The target must be a direct child of the current node.
|
|
||||||
*
|
|
||||||
* @returns `{ success: true }` on success, or `{ success: false, reason: string }` on failure.
|
|
||||||
*/
|
|
||||||
export function moveToNode(runState: RunState, targetNodeId: string): RunMutationResult {
|
|
||||||
if (!canMoveTo(runState, targetNodeId)) {
|
|
||||||
return { success: false, reason: '无法移动到该节点' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetNode = getNode(runState.map, targetNodeId);
|
|
||||||
if (!targetNode) {
|
|
||||||
return { success: false, reason: '目标节点不存在' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update current position
|
|
||||||
runState.currentNodeId = targetNodeId;
|
|
||||||
|
|
||||||
// Create new encounter state for the target node
|
|
||||||
const isResolved = runState.resolvedNodeIds.has(targetNodeId);
|
|
||||||
runState.currentEncounter = {
|
|
||||||
nodeId: targetNodeId,
|
|
||||||
resolved: isResolved,
|
|
||||||
};
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Encounter management --
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks the current encounter as resolved with optional result data.
|
|
||||||
*/
|
|
||||||
export function resolveEncounter(runState: RunState, result?: { goldEarned?: number; hpLost?: number; hpGained?: number; itemRewards?: string[] }): RunMutationResult {
|
|
||||||
if (runState.currentEncounter.resolved) {
|
|
||||||
return { success: false, reason: '该遭遇已解决' };
|
|
||||||
}
|
|
||||||
|
|
||||||
runState.currentEncounter.resolved = true;
|
|
||||||
runState.currentEncounter.result = result;
|
|
||||||
runState.resolvedNodeIds.add(runState.currentNodeId);
|
|
||||||
|
|
||||||
// Apply result effects
|
|
||||||
if (result) {
|
|
||||||
if (result.goldEarned) {
|
|
||||||
runState.player.gold += result.goldEarned;
|
|
||||||
}
|
|
||||||
if (result.hpLost) {
|
|
||||||
runState.player.currentHp = Math.max(0, runState.player.currentHp - result.hpLost);
|
|
||||||
}
|
|
||||||
if (result.hpGained) {
|
|
||||||
runState.player.currentHp = Math.min(runState.player.maxHp, runState.player.currentHp + result.hpGained);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether the encounter at the given node has been resolved.
|
|
||||||
*/
|
|
||||||
export function isEncounterResolved(runState: RunState, nodeId: string): boolean {
|
|
||||||
return runState.resolvedNodeIds.has(nodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Player stats --
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Damages the player. Clamps HP to 0 minimum.
|
|
||||||
*/
|
|
||||||
export function damagePlayer(runState: RunState, amount: number): void {
|
|
||||||
runState.player.currentHp = Math.max(0, runState.player.currentHp - amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Heals the player. Clamps HP to maxHp.
|
|
||||||
*/
|
|
||||||
export function healPlayer(runState: RunState, amount: number): void {
|
|
||||||
runState.player.currentHp = Math.min(runState.player.maxHp, runState.player.currentHp + amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the player's maximum HP and adjusts current HP proportionally.
|
|
||||||
*/
|
|
||||||
export function setMaxHp(runState: RunState, newMax: number): void {
|
|
||||||
const diff = newMax - runState.player.maxHp;
|
|
||||||
runState.player.maxHp = newMax;
|
|
||||||
runState.player.currentHp = Math.max(0, Math.min(newMax, runState.player.currentHp + diff));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds gold to the player.
|
|
||||||
*/
|
|
||||||
export function addGold(runState: RunState, amount: number): void {
|
|
||||||
runState.player.gold += amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spends gold. Returns false if the player doesn't have enough gold.
|
|
||||||
*/
|
|
||||||
export function spendGold(runState: RunState, amount: number): RunMutationResult {
|
|
||||||
if (amount <= 0) {
|
|
||||||
return { success: false, reason: '金额必须大于零' };
|
|
||||||
}
|
|
||||||
if (runState.player.gold < amount) {
|
|
||||||
return { success: false, reason: '金币不足' };
|
|
||||||
}
|
|
||||||
|
|
||||||
runState.player.gold -= amount;
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Inventory management --
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds an item from CSV data to the inventory.
|
|
||||||
* Finds the first available valid position.
|
|
||||||
*
|
|
||||||
* @returns The placed item instance, or undefined if no valid position exists.
|
|
||||||
*/
|
|
||||||
export function addItem(
|
|
||||||
runState: RunState,
|
|
||||||
itemData: ItemData
|
|
||||||
): GameItem | undefined {
|
|
||||||
const shape = parseShapeString(itemData.shape);
|
|
||||||
return tryPlaceItemInInventory(runState.inventory, itemData, shape, runState._idCounter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes an item from the inventory by its instance ID.
|
|
||||||
*/
|
|
||||||
export function removeItem(runState: RunState, instanceId: string): void {
|
|
||||||
gridRemoveItem(runState.inventory, instanceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Query helpers --
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the node the player is currently at.
|
|
||||||
*/
|
|
||||||
export function getCurrentNode(runState: RunState): MapNode | undefined {
|
|
||||||
return getNode(runState.map, runState.currentNodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all reachable child nodes from the current position.
|
|
||||||
*/
|
|
||||||
export function getReachableChildren(runState: RunState): MapNode[] {
|
|
||||||
const currentNode = getNode(runState.map, runState.currentNodeId);
|
|
||||||
if (!currentNode) return [];
|
|
||||||
|
|
||||||
return currentNode.childIds
|
|
||||||
.map(id => getNode(runState.map, id))
|
|
||||||
.filter((n): n is MapNode => n !== undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns available children that haven't been resolved yet.
|
|
||||||
*/
|
|
||||||
export function getUnresolvedChildren(runState: RunState): MapNode[] {
|
|
||||||
return getReachableChildren(runState).filter(
|
|
||||||
node => !runState.resolvedNodeIds.has(node.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the current encounter is the start node (no encounter).
|
|
||||||
*/
|
|
||||||
export function isAtStartNode(runState: RunState): boolean {
|
|
||||||
return runState.currentNodeId === runState.map.layers[0]?.nodes[0]?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the current encounter is the end node.
|
|
||||||
*/
|
|
||||||
export function isAtEndNode(runState: RunState): boolean {
|
|
||||||
const endLayer = runState.map.layers[runState.map.layers.length - 1];
|
|
||||||
return endLayer?.nodes[0]?.id === runState.currentNodeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Internal helpers --
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a unique item instance ID.
|
|
||||||
*/
|
|
||||||
function generateInstanceId(counter: { value: number }): string {
|
|
||||||
counter.value++;
|
|
||||||
return `item-${counter.value}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tries to place an item in the inventory by scanning all possible positions.
|
|
||||||
* Returns the placed item instance, or undefined if no valid position exists.
|
|
||||||
*/
|
|
||||||
function tryPlaceItemInInventory(
|
|
||||||
inventory: GridInventory<GameItemMeta>,
|
|
||||||
itemData: ItemData,
|
|
||||||
shape: ParsedShape,
|
|
||||||
idCounter: { value: number }
|
|
||||||
): GameItem | undefined {
|
|
||||||
const instanceId = generateInstanceId(idCounter);
|
|
||||||
|
|
||||||
// Try to find a valid position
|
|
||||||
for (let y = 0; y <= inventory.height - shape.height; y++) {
|
|
||||||
for (let x = 0; x <= inventory.width - shape.width; x++) {
|
|
||||||
const transform = {
|
|
||||||
...IDENTITY_TRANSFORM,
|
|
||||||
offset: { x, y },
|
|
||||||
};
|
|
||||||
|
|
||||||
const validation = validatePlacement(inventory, shape, transform);
|
|
||||||
if (validation.valid) {
|
|
||||||
const item: GameItem = {
|
|
||||||
id: instanceId,
|
|
||||||
shape,
|
|
||||||
transform,
|
|
||||||
meta: {
|
|
||||||
itemData,
|
|
||||||
shape,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
placeItem(inventory, item);
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import type { PointCrawlMap } from "../map/types";
|
|
||||||
import type { GridInventory, InventoryItem } from "../grid-inventory/types";
|
|
||||||
import type { ParsedShape } from "../utils/parse-shape";
|
|
||||||
import { ItemData } from "@/samples/slay-the-spire-like/system/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of an encounter (combat, event, etc.).
|
|
||||||
*/
|
|
||||||
export interface EncounterResult {
|
|
||||||
/** Gold earned from the encounter */
|
|
||||||
goldEarned?: number;
|
|
||||||
/** HP lost during the encounter */
|
|
||||||
hpLost?: number;
|
|
||||||
/** HP gained (e.g., from camp heal) */
|
|
||||||
hpGained?: number;
|
|
||||||
/** Item IDs rewarded */
|
|
||||||
itemRewards?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runtime state of an encounter at a specific node.
|
|
||||||
*/
|
|
||||||
export interface EncounterState {
|
|
||||||
/** The node ID where this encounter is located */
|
|
||||||
nodeId: string;
|
|
||||||
/** Whether the encounter has been resolved */
|
|
||||||
resolved: boolean;
|
|
||||||
/** Optional result data after resolution */
|
|
||||||
result?: EncounterResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Metadata attached to each inventory item instance.
|
|
||||||
* Bridges CSV item data with the grid inventory system.
|
|
||||||
*/
|
|
||||||
export interface GameItemMeta {
|
|
||||||
/** Original CSV item data */
|
|
||||||
itemData: ItemData;
|
|
||||||
/** Parsed shape for grid placement */
|
|
||||||
shape: ParsedShape;
|
|
||||||
/** Consumed uses, if card cost type is uses**/
|
|
||||||
consumedUses?: number;
|
|
||||||
/** Effects applied to the item */
|
|
||||||
effects?: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An item instance in the game inventory.
|
|
||||||
* Extends InventoryItem with game-specific metadata.
|
|
||||||
*/
|
|
||||||
export type GameItem = InventoryItem<GameItemMeta>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Player runtime state.
|
|
||||||
*/
|
|
||||||
export interface PlayerState {
|
|
||||||
/** Maximum HP */
|
|
||||||
maxHp: number;
|
|
||||||
/** Current HP */
|
|
||||||
currentHp: number;
|
|
||||||
/** Current gold */
|
|
||||||
gold: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full run state for a game session.
|
|
||||||
* Designed to be used inside `MutableSignal.produce()` callbacks.
|
|
||||||
*/
|
|
||||||
export interface RunState {
|
|
||||||
/** Generated point crawl map */
|
|
||||||
map: PointCrawlMap;
|
|
||||||
/** Player HP and gold */
|
|
||||||
player: PlayerState;
|
|
||||||
/** Grid inventory with placed items */
|
|
||||||
inventory: GridInventory<GameItemMeta>;
|
|
||||||
/** Current node ID where the player is located */
|
|
||||||
currentNodeId: string;
|
|
||||||
/** State of the encounter at the current node */
|
|
||||||
currentEncounter: EncounterState;
|
|
||||||
/** Set of node IDs whose encounters have been resolved */
|
|
||||||
resolvedNodeIds: Set<string>;
|
|
||||||
/** Internal counter for generating unique item instance IDs */
|
|
||||||
_idCounter: { value: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of a mutation operation on the run state.
|
|
||||||
*/
|
|
||||||
export type RunMutationResult =
|
|
||||||
| { success: true }
|
|
||||||
| { success: false; reason: string };
|
|
||||||
|
|
@ -56,9 +56,9 @@ export type EncounterType =
|
||||||
| "shop"
|
| "shop"
|
||||||
| "camp"
|
| "camp"
|
||||||
| "curio";
|
| "curio";
|
||||||
export type EncounterData = {
|
export type EncounterData<T extends EncounterType = EncounterType> = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly type: EncounterType;
|
readonly type: T;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
readonly enemies: readonly [
|
readonly enemies: readonly [
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import type {
|
||||||
GridInventory,
|
GridInventory,
|
||||||
InventoryItem,
|
InventoryItem,
|
||||||
} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
|
} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
|
||||||
import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress/types";
|
import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types";
|
||||||
import type { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
|
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";
|
import type { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import {
|
||||||
GridInventory,
|
GridInventory,
|
||||||
InventoryItem,
|
InventoryItem,
|
||||||
} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
|
} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
|
||||||
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress/types";
|
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types";
|
||||||
import { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
|
import { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
|
||||||
import { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
|
import { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -1,224 +1,244 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
generateDeckFromInventory,
|
generateDeckFromInventory,
|
||||||
createCard,
|
createCard,
|
||||||
createDeckRegions,
|
createDeckRegions,
|
||||||
createPlayerDeck,
|
createPlayerDeck,
|
||||||
generateCardId,
|
generateCardId,
|
||||||
} from '@/samples/slay-the-spire-like/system/deck/factory';
|
} from "@/samples/slay-the-spire-like/system/deck/factory";
|
||||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/system/grid-inventory';
|
import {
|
||||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory';
|
createGridInventory,
|
||||||
import type { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types';
|
placeItem,
|
||||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/system/utils/shape-collision';
|
} from "@/samples/slay-the-spire-like/system/grid-inventory";
|
||||||
import { parseShapeString } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
|
import type {
|
||||||
import type { CardData, ItemData } from '@/samples/slay-the-spire-like/system/types';
|
GridInventory,
|
||||||
|
InventoryItem,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/grid-inventory";
|
||||||
|
import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/encounter/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.
|
* Helper: create a minimal CardData for testing.
|
||||||
*/
|
*/
|
||||||
function createTestCardData(id: string, name: string, desc: string): CardData {
|
function createTestCardData(id: string, name: string, desc: string): CardData {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
desc,
|
desc,
|
||||||
type: 'item',
|
type: "item",
|
||||||
costType: 'energy',
|
costType: "energy",
|
||||||
costCount: 1,
|
costCount: 1,
|
||||||
targetType: 'single',
|
targetType: "single",
|
||||||
effects: [],
|
effects: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: create a minimal ItemData for testing.
|
* Helper: create a minimal ItemData for testing.
|
||||||
*/
|
*/
|
||||||
function createTestItemData(id: string, name: string, shapeStr: string, desc: string): ItemData {
|
function createTestItemData(
|
||||||
return {
|
id: string,
|
||||||
id,
|
name: string,
|
||||||
type: 'weapon',
|
shapeStr: string,
|
||||||
name,
|
desc: string,
|
||||||
shape: shapeStr,
|
): ItemData {
|
||||||
card: createTestCardData(id, name, desc),
|
return {
|
||||||
price: 10,
|
id,
|
||||||
description: desc,
|
type: "weapon",
|
||||||
};
|
name,
|
||||||
|
shape: shapeStr,
|
||||||
|
card: createTestCardData(id, name, desc),
|
||||||
|
price: 10,
|
||||||
|
description: desc,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: create a minimal GameItemMeta for testing.
|
* Helper: create a minimal GameItemMeta for testing.
|
||||||
*/
|
*/
|
||||||
function createTestMeta(name: string, desc: string, shapeStr: string): GameItemMeta {
|
function createTestMeta(
|
||||||
const shape = parseShapeString(shapeStr);
|
name: string,
|
||||||
const itemData = createTestItemData(name.toLowerCase(), name, shapeStr, desc);
|
desc: string,
|
||||||
return {
|
shapeStr: string,
|
||||||
itemData,
|
): GameItemMeta {
|
||||||
shape,
|
const shape = parseShapeString(shapeStr);
|
||||||
};
|
const itemData = createTestItemData(name.toLowerCase(), name, shapeStr, desc);
|
||||||
|
return {
|
||||||
|
itemData,
|
||||||
|
shape,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: create a test inventory with some items.
|
* Helper: create a test inventory with some items.
|
||||||
*/
|
*/
|
||||||
function createTestInventory(): GridInventory<GameItemMeta> {
|
function createTestInventory(): GridInventory<GameItemMeta> {
|
||||||
const inv = createGridInventory<GameItemMeta>(6, 4);
|
const inv = createGridInventory<GameItemMeta>(6, 4);
|
||||||
|
|
||||||
// Item "短刀" with shape "oe" (2 cells)
|
// Item "短刀" with shape "oe" (2 cells)
|
||||||
const meta1 = createTestMeta('短刀', '【攻击3】【攻击3】', 'oe');
|
const meta1 = createTestMeta("短刀", "【攻击3】【攻击3】", "oe");
|
||||||
const item1: InventoryItem<GameItemMeta> = {
|
const item1: InventoryItem<GameItemMeta> = {
|
||||||
id: 'dagger-1',
|
id: "dagger-1",
|
||||||
shape: meta1.shape,
|
shape: meta1.shape,
|
||||||
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
||||||
meta: meta1,
|
meta: meta1,
|
||||||
};
|
};
|
||||||
placeItem(inv, item1);
|
placeItem(inv, item1);
|
||||||
|
|
||||||
// Item "盾" with shape "oesw" (4 cells)
|
// Item "盾" with shape "oesw" (4 cells)
|
||||||
const meta2 = createTestMeta('盾', '【防御3】', 'oesw');
|
const meta2 = createTestMeta("盾", "【防御3】", "oesw");
|
||||||
const item2: InventoryItem<GameItemMeta> = {
|
const item2: InventoryItem<GameItemMeta> = {
|
||||||
id: 'shield-1',
|
id: "shield-1",
|
||||||
shape: meta2.shape,
|
shape: meta2.shape,
|
||||||
transform: { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 0 } },
|
transform: { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 0 } },
|
||||||
meta: meta2,
|
meta: meta2,
|
||||||
};
|
};
|
||||||
placeItem(inv, item2);
|
placeItem(inv, item2);
|
||||||
|
|
||||||
return inv;
|
return inv;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('deck/factory', () => {
|
describe("deck/factory", () => {
|
||||||
describe('generateCardId', () => {
|
describe("generateCardId", () => {
|
||||||
it('should generate deterministic unique IDs', () => {
|
it("should generate deterministic unique IDs", () => {
|
||||||
expect(generateCardId('item-1', 0)).toBe('card-item-1-0');
|
expect(generateCardId("item-1", 0)).toBe("card-item-1-0");
|
||||||
expect(generateCardId('item-1', 1)).toBe('card-item-1-1');
|
expect(generateCardId("item-1", 1)).toBe("card-item-1-1");
|
||||||
expect(generateCardId('item-2', 0)).toBe('card-item-2-0');
|
expect(generateCardId("item-2", 0)).toBe("card-item-2-0");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateDeckFromInventory", () => {
|
||||||
|
it("should generate correct number of cards based on shape cell counts", () => {
|
||||||
|
const inv = createTestInventory();
|
||||||
|
|
||||||
|
// "短刀" has 2 cells, "盾" has 4 cells = 6 total
|
||||||
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
|
expect(Object.keys(deck.cards).length).toBe(6);
|
||||||
|
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([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createCard', () => {
|
it("should link cards to their source items", () => {
|
||||||
it('should create a card with itemId and cardData', () => {
|
const inv = createTestInventory();
|
||||||
const cardData = createTestCardData('wound', '伤口', '无法被弃牌');
|
const deck = generateDeckFromInventory(inv);
|
||||||
const card = createCard('wound-1', cardData, 0);
|
|
||||||
|
|
||||||
expect(card.id).toBe('card-wound-1-0');
|
const daggerCards = Object.values(deck.cards).filter(
|
||||||
expect(card.itemId).toBe('wound-1');
|
(c) => c.itemId === "dagger-1",
|
||||||
expect(card.cardData).toBe(cardData);
|
);
|
||||||
expect(card.regionId).toBe('');
|
const shieldCards = Object.values(deck.cards).filter(
|
||||||
expect(card.position).toEqual([]);
|
(c) => c.itemId === "shield-1",
|
||||||
});
|
);
|
||||||
|
|
||||||
|
expect(daggerCards.length).toBe(2);
|
||||||
|
expect(shieldCards.length).toBe(4);
|
||||||
|
|
||||||
|
// Verify card data
|
||||||
|
expect(daggerCards[0].cardData.name).toBe("短刀");
|
||||||
|
expect(shieldCards[0].cardData.name).toBe("盾");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateDeckFromInventory', () => {
|
it("should set displayName and description from item data", () => {
|
||||||
it('should generate correct number of cards based on shape cell counts', () => {
|
const inv = createTestInventory();
|
||||||
const inv = createTestInventory();
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
// "短刀" has 2 cells, "盾" has 4 cells = 6 total
|
for (const card of Object.values(deck.cards)) {
|
||||||
const deck = generateDeckFromInventory(inv);
|
expect(card.cardData.name).toBeTruthy();
|
||||||
|
expect(card.cardData.desc).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
expect(Object.keys(deck.cards).length).toBe(6);
|
const daggerCard = Object.values(deck.cards).find(
|
||||||
expect(deck.regions.drawPile.childIds.length).toBe(6);
|
(c) => c.cardData.name === "短刀",
|
||||||
expect(deck.regions.hand.childIds).toEqual([]);
|
);
|
||||||
expect(deck.regions.discardPile.childIds).toEqual([]);
|
expect(daggerCard?.cardData.name).toBe("短刀");
|
||||||
expect(deck.regions.exhaustPile.childIds).toEqual([]);
|
expect(daggerCard?.cardData.desc).toBe("【攻击3】【攻击3】");
|
||||||
});
|
|
||||||
|
|
||||||
it('should link cards to their source items', () => {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const deck = generateDeckFromInventory(inv);
|
|
||||||
|
|
||||||
const daggerCards = Object.values(deck.cards).filter(
|
|
||||||
c => c.itemId === 'dagger-1'
|
|
||||||
);
|
|
||||||
const shieldCards = Object.values(deck.cards).filter(
|
|
||||||
c => c.itemId === 'shield-1'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(daggerCards.length).toBe(2);
|
|
||||||
expect(shieldCards.length).toBe(4);
|
|
||||||
|
|
||||||
// Verify card data
|
|
||||||
expect(daggerCards[0].cardData.name).toBe('短刀');
|
|
||||||
expect(shieldCards[0].cardData.name).toBe('盾');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set displayName and description from item data', () => {
|
|
||||||
const inv = createTestInventory();
|
|
||||||
const deck = generateDeckFromInventory(inv);
|
|
||||||
|
|
||||||
for (const card of Object.values(deck.cards)) {
|
|
||||||
expect(card.cardData.name).toBeTruthy();
|
|
||||||
expect(card.cardData.desc).toBeTruthy();
|
|
||||||
}
|
|
||||||
|
|
||||||
const daggerCard = Object.values(deck.cards).find(
|
|
||||||
c => c.cardData.name === '短刀'
|
|
||||||
);
|
|
||||||
expect(daggerCard?.cardData.name).toBe('短刀');
|
|
||||||
expect(daggerCard?.cardData.desc).toBe('【攻击3】【攻击3】');
|
|
||||||
});
|
|
||||||
|
|
||||||
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.itemId === 'dagger-1'
|
|
||||||
);
|
|
||||||
|
|
||||||
const ids = daggerCards.map(c => c.id);
|
|
||||||
const uniqueIds = new Set(ids);
|
|
||||||
expect(uniqueIds.size).toBe(ids.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty inventory', () => {
|
|
||||||
const inv = createGridInventory<GameItemMeta>(6, 4);
|
|
||||||
const deck = generateDeckFromInventory(inv);
|
|
||||||
|
|
||||||
expect(Object.keys(deck.cards).length).toBe(0);
|
|
||||||
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.regions.drawPile.childIds) {
|
|
||||||
expect(deck.cards[cardId]).toBeDefined();
|
|
||||||
}
|
|
||||||
|
|
||||||
// All cards are in draw pile
|
|
||||||
expect(new Set(deck.regions.drawPile.childIds).size).toBe(Object.keys(deck.cards).length);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createDeckRegions', () => {
|
it("should assign unique IDs to each card from same item", () => {
|
||||||
it('should create regions for all deck zones', () => {
|
const inv = createTestInventory();
|
||||||
const regions = createDeckRegions();
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
expect(regions.drawPile.id).toBe('drawPile');
|
const daggerCards = Object.values(deck.cards).filter(
|
||||||
expect(regions.hand.id).toBe('hand');
|
(c) => c.itemId === "dagger-1",
|
||||||
expect(regions.discardPile.id).toBe('discardPile');
|
);
|
||||||
expect(regions.exhaustPile.id).toBe('exhaustPile');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have empty childIds initially', () => {
|
const ids = daggerCards.map((c) => c.id);
|
||||||
const regions = createDeckRegions();
|
const uniqueIds = new Set(ids);
|
||||||
|
expect(uniqueIds.size).toBe(ids.length);
|
||||||
expect(regions.drawPile.childIds).toEqual([]);
|
|
||||||
expect(regions.hand.childIds).toEqual([]);
|
|
||||||
expect(regions.discardPile.childIds).toEqual([]);
|
|
||||||
expect(regions.exhaustPile.childIds).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createPlayerDeck', () => {
|
it("should handle empty inventory", () => {
|
||||||
it('should create an empty deck structure', () => {
|
const inv = createGridInventory<GameItemMeta>(6, 4);
|
||||||
const deck = createPlayerDeck();
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
expect(deck.cards).toEqual({});
|
expect(Object.keys(deck.cards).length).toBe(0);
|
||||||
expect(deck.regions.drawPile.childIds).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([]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should place all cards in draw pile initially", () => {
|
||||||
|
const inv = createTestInventory();
|
||||||
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
|
for (const cardId of deck.regions.drawPile.childIds) {
|
||||||
|
expect(deck.cards[cardId]).toBeDefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
// All cards are in draw pile
|
||||||
|
expect(new Set(deck.regions.drawPile.childIds).size).toBe(
|
||||||
|
Object.keys(deck.cards).length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createDeckRegions", () => {
|
||||||
|
it("should create regions for all deck zones", () => {
|
||||||
|
const regions = createDeckRegions();
|
||||||
|
|
||||||
|
expect(regions.drawPile.id).toBe("drawPile");
|
||||||
|
expect(regions.hand.id).toBe("hand");
|
||||||
|
expect(regions.discardPile.id).toBe("discardPile");
|
||||||
|
expect(regions.exhaustPile.id).toBe("exhaustPile");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have empty childIds initially", () => {
|
||||||
|
const regions = createDeckRegions();
|
||||||
|
|
||||||
|
expect(regions.drawPile.childIds).toEqual([]);
|
||||||
|
expect(regions.hand.childIds).toEqual([]);
|
||||||
|
expect(regions.discardPile.childIds).toEqual([]);
|
||||||
|
expect(regions.exhaustPile.childIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createPlayerDeck", () => {
|
||||||
|
it("should create an empty deck structure", () => {
|
||||||
|
const deck = createPlayerDeck();
|
||||||
|
|
||||||
|
expect(deck.cards).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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue