Compare commits

..

5 Commits

Author SHA1 Message Date
hypercross 88d31430a6 test: refactor slay-the-spire-like combat tests
Update combat effect tests to use the new transform offset structure
and fix type mismatches in entity and inventory creation.
2026-04-20 11:37:18 +08:00
hypercross 90cb97e0ae chore: add @types/node and update tsconfig
Add @types/node as a dev dependency and include it in tsconfig types
to support Node.js globals. Also expand tsconfig include to cover
configuration files.
2026-04-20 11:34:40 +08:00
hypercross 5e172c61bb refactor(slay-the-spire-like): use IRunContext in combat system
Update the combat system to use `IRunContext` instead of passing
raw inventory objects. This provides a cleaner abstraction for
accessing item data and managing consumed uses.

- Update `canPlayCard` and `payCardCost` to use `IRunContext`
- Pass `IRunContext` through the trigger registration chain
- Fix logic in `canPlayCard` for "uses" cost validation
- Update combat effect tests to use a mock `IRunContext`
2026-04-20 11:25:52 +08:00
hypercross 5019bc6324 refactor(slay-the-spire-like): use IRunContext for combat logic
Decouple combat systems from the inventory and progress state by
introducing `IRunContext`. This replaces direct access to
`GridInventory` and `GameItemMeta` with abstract methods for
retrieving item data, neighbors, and managing consumed uses.
2026-04-20 11:03:44 +08:00
hypercross 9bed2ca13e refactor(slay-the-spire-like): rename depletion to consumedUses
Rename `depletion` to `consumedUses` in `GameItemMeta` to better reflect
its purpose. Update combat effect logic and tests to use the new field
name. Also apply consistent formatting and import organization to
combat effect modules.
2026-04-20 10:28:58 +08:00
14 changed files with 381 additions and 263 deletions

18
package-lock.json generated
View File

@ -10,6 +10,7 @@
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@preact/signals-core": "^1.5.1", "@preact/signals-core": "^1.5.1",
"@types/node": "^25.6.0",
"inline-schema": "file:../inline-schema", "inline-schema": "file:../inline-schema",
"mutative": "^1.3.0", "mutative": "^1.3.0",
"tsup": "^8.0.2", "tsup": "^8.0.2",
@ -958,6 +959,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
@ -2469,6 +2480,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",

View File

@ -30,6 +30,7 @@
}, },
"devDependencies": { "devDependencies": {
"@preact/signals-core": "^1.5.1", "@preact/signals-core": "^1.5.1",
"@types/node": "^25.6.0",
"inline-schema": "file:../inline-schema", "inline-schema": "file:../inline-schema",
"mutative": "^1.3.0", "mutative": "^1.3.0",
"tsup": "^8.0.2", "tsup": "^8.0.2",

View File

@ -1,11 +1,10 @@
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers"; import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
import { getCombatEntity } from "@/samples/slay-the-spire-like/system/combat/effects"; import { getCombatEntity } from "@/samples/slay-the-spire-like/system/combat/effects";
import { getAdjacentItems } from "@/samples/slay-the-spire-like/system/grid-inventory";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress";
import { EffectData } from "@/samples/slay-the-spire-like/system/types"; import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import getEffects from "../effect.csv"; import getEffects from "../effect.csv";
import { IRunContext } from "@/samples/slay-the-spire-like/system/combat/types";
export function addCardEventTriggers(triggers: Triggers) { export function addCardEventTriggers(triggers: Triggers, run: IRunContext) {
const effects = getEffects(); const effects = getEffects();
function findEffect(id: string): EffectData { function findEffect(id: string): EffectData {
@ -67,21 +66,21 @@ export function addCardEventTriggers(triggers: Triggers) {
if (!card) return; if (!card) return;
const playedItemId = card.itemId; const playedItemId = card.itemId;
const adjacent = getAdjacentItems<GameItemMeta>( const adjacent = run.getNeighborItems(playedItemId);
ctx.game.value.inventory, for (const adjItemId of adjacent) {
playedItemId,
);
for (const [adjItemId] of adjacent) {
const adjEffects = ctx.game.value.player.itemEffects[adjItemId]; const adjEffects = ctx.game.value.player.itemEffects[adjItemId];
if (!adjEffects) continue; if (!adjEffects) continue;
const burn = adjEffects.burnForEnergy; const burn = adjEffects.burnForEnergy;
if (!burn || burn.stacks <= 0) continue; if (!burn || burn.stacks <= 0) continue;
const item = run.getItemData(adjItemId);
const maxUses =
item?.card.costType === "energy" ? item.card.costCount : 0;
const consumed = run.getConsumedUses(adjItemId);
const toConsume = Math.min(maxUses - consumed, burn.stacks);
await run.setConsumedUsesAsync(adjItemId, consumed + toConsume);
await ctx.game.produceAsync((draft) => { await ctx.game.produceAsync((draft) => {
const item = draft.inventory.items.get(adjItemId);
if (item) {
draft.inventory.items.delete(adjItemId);
}
draft.player.energy += burn.stacks; draft.player.energy += burn.stacks;
delete draft.player.itemEffects[adjItemId]; delete draft.player.itemEffects[adjItemId];
}); });

View File

@ -1,12 +1,13 @@
import { IRunContext } from "@/samples/slay-the-spire-like/system/combat/types";
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers"; import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
import { addInstantEffectTriggers } from "./instant"; import { addInstantEffectTriggers } from "./instant";
import { addDamageTriggers } from "./damage"; import { addDamageTriggers } from "./damage";
import { addTurnStartTriggers } from "./turn-start"; import { addTurnStartTriggers } from "./turn-start";
import { addCardEventTriggers } from "./card-events"; import { addCardEventTriggers } from "./card-events";
export function addDesertTriggers(triggers: Triggers) { export function addDesertTriggers(triggers: Triggers, run: IRunContext) {
addInstantEffectTriggers(triggers); addInstantEffectTriggers(triggers);
addDamageTriggers(triggers); addDamageTriggers(triggers);
addTurnStartTriggers(triggers); addTurnStartTriggers(triggers);
addCardEventTriggers(triggers); addCardEventTriggers(triggers, run);
} }

View File

@ -1,134 +1,168 @@
import {CombatEntity, CombatGameContext, CombatState, EffectTable, PlayerEntity} from "./types";
import { import {
CardData, CombatEntity,
CardEffectTarget, CombatGameContext,
CardTargetType, CombatState,
EffectData, EffectTable,
EffectTarget IRunContext,
PlayerEntity,
} from "./types";
import {
CardData,
CardEffectTarget,
CardTargetType,
EffectData,
EffectTarget,
} from "@/samples/slay-the-spire-like/system/types"; } 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){ export function addEffect(
let current = effects[effect.id]; effects: EffectTable,
effect: EffectData,
if(!current) current = {data: effect, stacks}; stacks: number,
else current.stacks += stacks; ) {
let current = effects[effect.id];
if(current.stacks === 0 && effects[effect.id])
delete effects[effect.id]; if (!current) current = { data: effect, stacks };
else if(current.stacks !== 0 && !effects[effect.id]) else current.stacks += stacks;
effects[effect.id] = current;
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){ export function addEntityEffect(
addEffect(entity.effects, effect, stacks); entity: CombatEntity,
effect: EffectData,
stacks: number,
) {
addEffect(entity.effects, effect, stacks);
} }
export function addItemEffect(entity: PlayerEntity, itemKey: string, effect: EffectData, stacks: number){ export function addItemEffect(
entity.itemEffects[itemKey] = entity.itemEffects[itemKey] || {}; entity: PlayerEntity,
addEffect(entity.itemEffects[itemKey], effect, stacks); itemKey: string,
effect: EffectData,
stacks: number,
) {
entity.itemEffects[itemKey] = entity.itemEffects[itemKey] || {};
addEffect(entity.itemEffects[itemKey], effect, stacks);
} }
export function onEntityEffectUpkeep(entity: CombatEntity){ export function onEntityEffectUpkeep(entity: CombatEntity) {
for(const effect of Object.values(entity.effects)){ for (const effect of Object.values(entity.effects)) {
const lifecycle = effect.data.lifecycle; const lifecycle = effect.data.lifecycle;
if(lifecycle === 'temporary') if (lifecycle === "temporary")
addEntityEffect(entity, effect.data, -effect.stacks); addEntityEffect(entity, effect.data, -effect.stacks);
else if(lifecycle === 'lingering') else if (lifecycle === "lingering")
addEntityEffect(entity, effect.data, effect.stacks >= 0 ? -1 : 1); 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 onEntityPostureDamage(entity: CombatEntity, damage: number){ export function onItemPlay(entity: PlayerEntity, itemKey: string) {
for(const effect of Object.values(entity.effects)){ const effects = entity.itemEffects[itemKey];
const lifecycle = effect.data.lifecycle; if (!effects) return;
if(lifecycle === 'posture') for (const effect of Object.values(effects)) {
addEntityEffect(entity, effect.data, -Math.min(damage, effect.stacks)); if (effect.data.lifecycle === "itemUntilPlay") {
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
} }
}
} }
export function onPlayerItemEffectUpkeep(entity: PlayerEntity){ export function onItemDiscard(entity: PlayerEntity, itemKey: string) {
for(const [itemKey, itemEffects] of Object.entries(entity.itemEffects)){ const effects = entity.itemEffects[itemKey];
for(const effect of Object.values(itemEffects)){ if (!effects) return;
const lifecycle = effect.data.lifecycle; for (const effect of Object.values(effects)) {
if(lifecycle === 'itemTemporary') if (effect.data.lifecycle === "itemUntilDiscard") {
addItemEffect(entity, itemKey, effect.data, -effect.stacks); 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) { export function* getAliveEnemies(state: CombatState) {
for (let enemy of state.enemies) { for (let enemy of state.enemies) {
if (enemy.isAlive) { if (enemy.isAlive) {
yield enemy; yield enemy;
}
} }
}
} }
export function* getEffectTargets(target: CardEffectTarget | EffectTarget, game: CombatGameContext, targetId?: string){ export function* getEffectTargets(
if(target === 'all' || target === 'team'){ target: CardEffectTarget | EffectTarget,
for(const enemy of getAliveEnemies(game.value)){ game: CombatGameContext,
yield enemy; targetId?: string,
} ) {
} else if(target === 'self') { if (target === "all" || target === "team") {
yield game.value.player; for (const enemy of getAliveEnemies(game.value)) {
} else if(target === 'target'){ yield enemy;
if(!targetId) return;
const entity = getCombatEntity(game.value, targetId);
if(entity) yield entity;
} else if(target === 'random'){
const aliveEnemies = [...getAliveEnemies(game.value)];
if(aliveEnemies.length === 0) return;
const index = game.rng.nextInt(aliveEnemies.length);
yield aliveEnemies[index];
} }
} else if (target === "self") {
yield game.value.player;
} else if (target === "target") {
if (!targetId) return;
const entity = getCombatEntity(game.value, targetId);
if (entity) yield entity;
} else if (target === "random") {
const aliveEnemies = [...getAliveEnemies(game.value)];
if (aliveEnemies.length === 0) return;
const index = game.rng.nextInt(aliveEnemies.length);
yield aliveEnemies[index];
}
} }
export function getCombatEntity(state: CombatState, entityKey: string){ export function getCombatEntity(state: CombatState, entityKey: string) {
return entityKey === 'player' ? state.player : state.enemies.find(e => e.id === entityKey); 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 { export function canPlayCard(
if (costType === 'energy') { player: PlayerEntity,
return player.energy >= costCount; costType: CardData["costType"],
} costCount: number,
if (costType === 'uses') { itemId: string,
const item = inventory.items.get(itemId); run: IRunContext,
if (!item || !item.meta) return false; ): boolean {
const depletion = item.meta.depletion ?? 0; if (costType === "energy") {
return depletion < costCount; return player.energy >= costCount;
} }
return true; if (costType === "uses") {
const item = run.getItemData(itemId);
if (!item) return false;
const maxUses = item?.card.costType === "uses" ? item.card.costCount : 0;
const consumed = run.getConsumedUses(itemId);
return consumed < maxUses;
}
return true;
} }
export function payCardCost(player: PlayerEntity, costType: CardData['costType'], costCount: number, itemId: string, inventory: GridInventory<GameItemMeta>): void { export async function payCardCost(
if (costType === 'energy') { player: PlayerEntity,
player.energy -= costCount; costType: CardData["costType"],
} else if (costType === 'uses') { costCount: number,
const item = inventory.items.get(itemId); itemId: string,
if (item && item.meta) { run: IRunContext,
item.meta.depletion = (item.meta.depletion ?? 0) + costCount; ): Promise<void> {
} if (costType === "energy") {
} player.energy -= costCount;
} else if (costType === "uses") {
const consumed = run.getConsumedUses(itemId);
await run.setConsumedUsesAsync(itemId, consumed + costCount);
}
} }

View File

@ -1,43 +1,56 @@
import { createPromptDef } from "@/core/game"; import { createPromptDef } from "@/core/game";
import {CombatGameContext} from "./types"; import { CombatGameContext, IRunContext } from "./types";
import {canPlayCard} from "@/samples/slay-the-spire-like/system/combat/effects"; import { canPlayCard } from "@/samples/slay-the-spire-like/system/combat/effects";
export const prompts = { export const prompts = {
mainAction: createPromptDef<[string, string?]>( mainAction: createPromptDef<[string, string?]>(
"main-action <cardId:string> [targetId:string]", "main-action <cardId:string> [targetId:string]",
"选择卡牌并指定目标" "选择卡牌并指定目标",
), ),
}; };
export async function promptMainAction(game: CombatGameContext){ export async function promptMainAction(
return await game.prompt(prompts.mainAction, (cardId, targetId) => { game: CombatGameContext,
if(cardId === 'end-turn') return { run: IRunContext,
action: 'end-turn' as 'end-turn' ) {
}; return await game.prompt(prompts.mainAction, (cardId, targetId) => {
if (cardId === "end-turn")
const exists = game.value.player.deck.regions.hand.childIds.includes(cardId); return {
if(!exists) throw `卡牌"${cardId}"不在手牌中`; action: "end-turn" as "end-turn",
};
const card = game.value.player.deck.cards[cardId];
const {cardData, itemId} = card; const exists =
if(!canPlayCard(game.value.player, cardData.costType, cardData.costCount, itemId, game.value.inventory)){ game.value.player.deck.regions.hand.childIds.includes(cardId);
throw `无法支付卡牌"${cardId}"的费用`; if (!exists) throw `卡牌"${cardId}"不在手牌中`;
}
const card = game.value.player.deck.cards[cardId];
const {targetType} = cardData; const { cardData, itemId } = card;
if(targetType === 'single'){ if (
if(!targetId) throw `请指定目标`; !canPlayCard(
const target = game.value.enemies.find(e => e.id === targetId); game.value.player,
if(!target) throw `目标"${targetId}"不存在`; cardData.costType,
if(!target.isAlive) throw `目标"${targetId}"已死亡`; cardData.costCount,
}else if(targetType === 'none'){ itemId,
if(targetId) throw `目标"${targetId}"无效`; run,
} )
) {
return { throw `无法支付卡牌"${cardId}"的费用`;
action: 'play' as 'play', }
cardId,
targetId 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,
};
});
}

View File

@ -1,4 +1,4 @@
import { CombatGameContext } from "./types"; import { CombatGameContext, IRunContext } from "./types";
import { import {
addEntityEffect, addEntityEffect,
addItemEffect, addItemEffect,
@ -51,7 +51,7 @@ type TriggerTypes = {
onIntentUpdate: { enemyId: string }; onIntentUpdate: { enemyId: string };
}; };
export function createTriggers() { export function createTriggers(run: IRunContext) {
const triggers = { const triggers = {
onCombatStart: createTrigger("onCombatStart"), onCombatStart: createTrigger("onCombatStart"),
onTurnStart: createTrigger("onTurnStart", async (ctx) => { onTurnStart: createTrigger("onTurnStart", async (ctx) => {
@ -89,7 +89,7 @@ export function createTriggers() {
card.cardData.costType, card.cardData.costType,
card.cardData.costCount, card.cardData.costCount,
card.itemId, card.itemId,
draft.inventory, run,
); );
moveToRegion(card, regions.hand, regions.discardPile); moveToRegion(card, regions.hand, regions.discardPile);
onItemPlay(draft.player, card.itemId); onItemPlay(draft.player, card.itemId);
@ -176,11 +176,8 @@ export function createTriggers() {
if (ctx.effect.lifecycle.startsWith("item")) { if (ctx.effect.lifecycle.startsWith("item")) {
if (ctx.cardId) { if (ctx.cardId) {
const card = ctx.game.value.player.deck.cards[ctx.cardId]; const card = ctx.game.value.player.deck.cards[ctx.cardId];
const nearby = getAdjacentItems<GameItemMeta>( const nearby = run.getNeighborItems(card.itemId);
ctx.game.value.inventory, for (const itemId of nearby) {
card.itemId,
);
for (const itemId of nearby.keys()) {
await ctx.game.produceAsync((draft) => { await ctx.game.produceAsync((draft) => {
addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks); addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks);
}); });
@ -269,9 +266,12 @@ export function createTriggers() {
return triggers; return triggers;
} }
export type Triggers = ReturnType<typeof createTriggers>; export type Triggers = ReturnType<typeof createTriggers>;
export function createStartWith(build: (triggers: Triggers) => void) { export function createStartWith(
const triggers = createTriggers(); build: (triggers: Triggers, run: IRunContext) => void,
build(triggers); run: IRunContext,
) {
const triggers = createTriggers(run);
build(triggers, run);
return async function (game: CombatGameContext) { return async function (game: CombatGameContext) {
await triggers.onCombatStart.execute(game, {}); await triggers.onCombatStart.execute(game, {});
@ -279,7 +279,7 @@ export function createStartWith(build: (triggers: Triggers) => void) {
while (true) { while (true) {
await triggers.onTurnStart.execute(game, { entityKey: "player" }); await triggers.onTurnStart.execute(game, { entityKey: "player" });
while (true) { while (true) {
const action = await promptMainAction(game); const action = await promptMainAction(game, run);
if (action.action === "end-turn") break; if (action.action === "end-turn") break;
if (action.action === "play") { if (action.action === "play") {
await triggers.onCardPlayed.execute(game, action); await triggers.onCardPlayed.execute(game, action);

View File

@ -2,10 +2,9 @@ import type { PlayerDeck } from "../deck/types";
import { import {
EnemyData, EnemyData,
IntentData, IntentData,
ItemData,
} from "@/samples/slay-the-spire-like/system/types"; } from "@/samples/slay-the-spire-like/system/types";
import { EffectData } 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 EffectTable = Record<string, { data: EffectData; stacks: number }>;
@ -46,7 +45,6 @@ export type LootEntry =
export type CombatState = { export type CombatState = {
enemies: EnemyEntity[]; enemies: EnemyEntity[];
player: PlayerEntity; player: PlayerEntity;
inventory: GridInventory<GameItemMeta>;
phase: CombatPhase; phase: CombatPhase;
turnNumber: number; turnNumber: number;
@ -55,5 +53,13 @@ export type CombatState = {
loot: LootEntry[]; loot: LootEntry[];
}; };
export interface IRunContext {
getItemData(id: string): ItemData | null;
getNeighborItems(id: string): Iterable<string>;
getConsumedUses(id: string): number;
setConsumedUsesAsync(id: string, uses: number): Promise<void>;
}
export type CombatGameContext = export type CombatGameContext =
import("@/core/game").IGameContextExport<CombatState>; import("@/core/game").IGameContextExport<CombatState>;

View File

@ -94,7 +94,6 @@ export function buildCombatState(runState: RunState): CombatState {
return { return {
enemies, enemies,
player, player,
inventory: runState.inventory,
phase: "playerTurn", phase: "playerTurn",
turnNumber: 1, turnNumber: 1,
result: null, result: null,

View File

@ -1,32 +1,32 @@
import type { PointCrawlMap } from '../map/types'; import type { PointCrawlMap } from "../map/types";
import type { GridInventory, InventoryItem } from '../grid-inventory/types'; import type { GridInventory, InventoryItem } from "../grid-inventory/types";
import type { ParsedShape } from '../utils/parse-shape'; import type { ParsedShape } from "../utils/parse-shape";
import {ItemData} from "@/samples/slay-the-spire-like/system/types"; import { ItemData } from "@/samples/slay-the-spire-like/system/types";
/** /**
* Result of an encounter (combat, event, etc.). * Result of an encounter (combat, event, etc.).
*/ */
export interface EncounterResult { export interface EncounterResult {
/** Gold earned from the encounter */ /** Gold earned from the encounter */
goldEarned?: number; goldEarned?: number;
/** HP lost during the encounter */ /** HP lost during the encounter */
hpLost?: number; hpLost?: number;
/** HP gained (e.g., from camp heal) */ /** HP gained (e.g., from camp heal) */
hpGained?: number; hpGained?: number;
/** Item IDs rewarded */ /** Item IDs rewarded */
itemRewards?: string[]; itemRewards?: string[];
} }
/** /**
* Runtime state of an encounter at a specific node. * Runtime state of an encounter at a specific node.
*/ */
export interface EncounterState { export interface EncounterState {
/** The node ID where this encounter is located */ /** The node ID where this encounter is located */
nodeId: string; nodeId: string;
/** Whether the encounter has been resolved */ /** Whether the encounter has been resolved */
resolved: boolean; resolved: boolean;
/** Optional result data after resolution */ /** Optional result data after resolution */
result?: EncounterResult; result?: EncounterResult;
} }
/** /**
@ -34,12 +34,14 @@ export interface EncounterState {
* Bridges CSV item data with the grid inventory system. * Bridges CSV item data with the grid inventory system.
*/ */
export interface GameItemMeta { export interface GameItemMeta {
/** Original CSV item data */ /** Original CSV item data */
itemData: ItemData; itemData: ItemData;
/** Parsed shape for grid placement */ /** Parsed shape for grid placement */
shape: ParsedShape; shape: ParsedShape;
/** Consumed uses, if card cost type is uses**/ /** Consumed uses, if card cost type is uses**/
depletion?: number; consumedUses?: number;
/** Effects applied to the item */
effects?: Record<string, number>;
} }
/** /**
@ -52,12 +54,12 @@ export type GameItem = InventoryItem<GameItemMeta>;
* Player runtime state. * Player runtime state.
*/ */
export interface PlayerState { export interface PlayerState {
/** Maximum HP */ /** Maximum HP */
maxHp: number; maxHp: number;
/** Current HP */ /** Current HP */
currentHp: number; currentHp: number;
/** Current gold */ /** Current gold */
gold: number; gold: number;
} }
/** /**
@ -65,23 +67,25 @@ export interface PlayerState {
* Designed to be used inside `MutableSignal.produce()` callbacks. * Designed to be used inside `MutableSignal.produce()` callbacks.
*/ */
export interface RunState { export interface RunState {
/** Generated point crawl map */ /** Generated point crawl map */
map: PointCrawlMap; map: PointCrawlMap;
/** Player HP and gold */ /** Player HP and gold */
player: PlayerState; player: PlayerState;
/** Grid inventory with placed items */ /** Grid inventory with placed items */
inventory: GridInventory<GameItemMeta>; inventory: GridInventory<GameItemMeta>;
/** Current node ID where the player is located */ /** Current node ID where the player is located */
currentNodeId: string; currentNodeId: string;
/** State of the encounter at the current node */ /** State of the encounter at the current node */
currentEncounter: EncounterState; currentEncounter: EncounterState;
/** Set of node IDs whose encounters have been resolved */ /** Set of node IDs whose encounters have been resolved */
resolvedNodeIds: Set<string>; resolvedNodeIds: Set<string>;
/** Internal counter for generating unique item instance IDs */ /** Internal counter for generating unique item instance IDs */
_idCounter: { value: number }; _idCounter: { value: number };
} }
/** /**
* Result of a mutation operation on the run state. * Result of a mutation operation on the run state.
*/ */
export type RunMutationResult = { success: true } | { success: false; reason: string }; export type RunMutationResult =
| { success: true }
| { success: false; reason: string };

View File

@ -17,10 +17,14 @@ import type {
CombatEntity, CombatEntity,
CombatState, CombatState,
EffectTable, EffectTable,
IRunContext,
PlayerEntity, PlayerEntity,
EnemyEntity, EnemyEntity,
} from "@/samples/slay-the-spire-like/system/combat/types"; } from "@/samples/slay-the-spire-like/system/combat/types";
import type { EffectData } from "@/samples/slay-the-spire-like/system/types"; import type {
EffectData,
ItemData,
} from "@/samples/slay-the-spire-like/system/types";
import type { import type {
CellKey, CellKey,
GridInventory, GridInventory,
@ -30,6 +34,31 @@ import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress
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";
function createRunContext(
items: Map<string, InventoryItem<GameItemMeta>>,
): IRunContext {
return {
getItemData(id: string): ItemData | null {
const item = items.get(id);
return item?.meta?.itemData ?? null;
},
getNeighborItems(_id: string): Iterable<string> {
return [];
},
getConsumedUses(id: string): number {
const item = items.get(id);
return item?.meta?.consumedUses ?? 0;
},
setConsumedUsesAsync(id: string, uses: number): Promise<void> {
const item = items.get(id);
if (item?.meta) {
item.meta.consumedUses = uses;
}
return Promise.resolve();
},
};
}
function createEffect( function createEffect(
id: string, id: string,
lifecycle: EffectData["lifecycle"], lifecycle: EffectData["lifecycle"],
@ -65,9 +94,7 @@ function createItem(
id: itemId, id: itemId,
shape: { id: "1x1", cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape, shape: { id: "1x1", cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
transform: { transform: {
x: 0, offset: { x: 0, y: 0 },
y: 0,
rotation: 0,
flipX: false, flipX: false,
flipY: false, flipY: false,
} as unknown as Transform2D, } as unknown as Transform2D,
@ -82,7 +109,7 @@ function createItem(
description: "", description: "",
}, },
shape: { id: "1x1", cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape, shape: { id: "1x1", cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
depletion: costType === "uses" ? depletion : undefined, consumedUses: costType === "uses" ? depletion : undefined,
}, },
}; };
} }
@ -94,13 +121,14 @@ function createInventory(
const occupied = new Set<CellKey>(); const occupied = new Set<CellKey>();
for (const item of items) { for (const item of items) {
map.set(item.id, item); map.set(item.id, item);
occupied.add(`${item.transform.x},${item.transform.y}`); occupied.add(`${item.transform.offset.x},${item.transform.offset.y}`);
} }
return { width: 6, height: 4, items: map, occupiedCells: occupied }; return { width: 6, height: 4, items: map, occupiedCells: occupied };
} }
function createCombatEntity(hp = 10, maxHp = 10): CombatEntity { function createCombatEntity(hp = 10, maxHp = 10): CombatEntity {
return { return {
id: "",
effects: {}, effects: {},
hp, hp,
maxHp, maxHp,
@ -130,9 +158,9 @@ function createEnemyEntity(id: string, hp = 10, maxHp = 10): EnemyEntity {
return { return {
...createCombatEntity(hp, maxHp), ...createCombatEntity(hp, maxHp),
id, id,
enemy: { id, name: id, description: "" }, enemy: { id, name: id, description: "", intents: null! },
intents: {}, intents: {},
currentIntentId: "", currentIntent: null!,
}; };
} }
@ -143,12 +171,6 @@ function createCombatState(
return { return {
player: createPlayerEntity(playerHp), player: createPlayerEntity(playerHp),
enemies, enemies,
inventory: {
width: 6,
height: 4,
items: new Map(),
occupiedCells: new Set(),
},
phase: "playerTurn", phase: "playerTurn",
turnNumber: 1, turnNumber: 1,
result: null, result: null,
@ -517,8 +539,9 @@ describe("combat/effects", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
player.energy = 3; player.energy = 3;
const inventory = createInventory([]); const inventory = createInventory([]);
const run = createRunContext(inventory.items);
const result = canPlayCard(player, "energy", 2, "any", inventory); const result = canPlayCard(player, "energy", 2, "any", run);
expect(result).toBe(true); expect(result).toBe(true);
}); });
@ -527,8 +550,9 @@ describe("combat/effects", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
player.energy = 1; player.energy = 1;
const inventory = createInventory([]); const inventory = createInventory([]);
const run = createRunContext(inventory.items);
const result = canPlayCard(player, "energy", 2, "any", inventory); const result = canPlayCard(player, "energy", 2, "any", run);
expect(result).toBe(false); expect(result).toBe(false);
}); });
@ -537,8 +561,9 @@ describe("combat/effects", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
const item = createItem("potion-1", "potion-card", "uses", 3, 1); const item = createItem("potion-1", "potion-card", "uses", 3, 1);
const inventory = createInventory([item]); const inventory = createInventory([item]);
const run = createRunContext(inventory.items);
const result = canPlayCard(player, "uses", 3, "potion-1", inventory); const result = canPlayCard(player, "uses", 3, "potion-1", run);
expect(result).toBe(true); expect(result).toBe(true);
}); });
@ -547,8 +572,9 @@ describe("combat/effects", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
const item = createItem("potion-1", "potion-card", "uses", 3, 3); const item = createItem("potion-1", "potion-card", "uses", 3, 3);
const inventory = createInventory([item]); const inventory = createInventory([item]);
const run = createRunContext(inventory.items);
const result = canPlayCard(player, "uses", 3, "potion-1", inventory); const result = canPlayCard(player, "uses", 3, "potion-1", run);
expect(result).toBe(false); expect(result).toBe(false);
}); });
@ -556,8 +582,9 @@ describe("combat/effects", () => {
it("should reject playing uses card when item not in inventory", () => { it("should reject playing uses card when item not in inventory", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
const inventory = createInventory([]); const inventory = createInventory([]);
const run = createRunContext(inventory.items);
const result = canPlayCard(player, "uses", 1, "missing", inventory); const result = canPlayCard(player, "uses", 1, "missing", run);
expect(result).toBe(false); expect(result).toBe(false);
}); });
@ -566,51 +593,56 @@ describe("combat/effects", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
player.energy = 0; player.energy = 0;
const inventory = createInventory([]); const inventory = createInventory([]);
const run = createRunContext(inventory.items);
const result = canPlayCard(player, "none", 0, "any", inventory); const result = canPlayCard(player, "none", 0, "any", run);
expect(result).toBe(true); expect(result).toBe(true);
}); });
}); });
describe("payCardCost", () => { describe("payCardCost", () => {
it("should deduct energy for energy cost card", () => { it("should deduct energy for energy cost card", async () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
player.energy = 3; player.energy = 3;
const inventory = createInventory([]); const inventory = createInventory([]);
const run = createRunContext(inventory.items);
payCardCost(player, "energy", 2, "any", inventory); await payCardCost(player, "energy", 2, "any", run);
expect(player.energy).toBe(1); expect(player.energy).toBe(1);
}); });
it("should increment depletion for uses cost card", () => { it("should increment depletion for uses cost card", async () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
const item = createItem("potion-1", "potion-card", "uses", 3, 1); const item = createItem("potion-1", "potion-card", "uses", 3, 1);
const inventory = createInventory([item]); const inventory = createInventory([item]);
const run = createRunContext(inventory.items);
payCardCost(player, "uses", 3, "potion-1", inventory); await payCardCost(player, "uses", 3, "potion-1", run);
expect(item.meta?.depletion).toBe(4); expect(item.meta?.consumedUses).toBe(4);
}); });
it("should do nothing for none cost card", () => { it("should do nothing for none cost card", async () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
player.energy = 3; player.energy = 3;
const inventory = createInventory([]); const inventory = createInventory([]);
const run = createRunContext(inventory.items);
payCardCost(player, "none", 0, "any", inventory); await payCardCost(player, "none", 0, "any", run);
expect(player.energy).toBe(3); expect(player.energy).toBe(3);
}); });
it("should handle missing item gracefully for uses cost", () => { it("should handle missing item gracefully for uses cost", async () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
const inventory = createInventory([]); const inventory = createInventory([]);
const run = createRunContext(inventory.items);
expect(() => await expect(
payCardCost(player, "uses", 1, "missing", inventory), payCardCost(player, "uses", 1, "missing", run),
).not.toThrow(); ).resolves.not.toThrow();
}); });
}); });
}); });

View File

@ -14,6 +14,7 @@ import { addTriggers } from "@/samples/slay-the-spire-like/data/desert/triggers"
import { import {
CombatState, CombatState,
EnemyEntity, EnemyEntity,
IRunContext,
} from "@/samples/slay-the-spire-like/system/combat/types"; } from "@/samples/slay-the-spire-like/system/combat/types";
import { EffectData } from "@/samples/slay-the-spire-like/system/types"; import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import { import {
@ -138,7 +139,6 @@ function createCombatState(overrides: Partial<CombatState> = {}): CombatState {
itemEffects: {}, itemEffects: {},
}, },
enemies: [], enemies: [],
inventory: createInventory([]),
phase: "playerTurn", phase: "playerTurn",
turnNumber: 1, turnNumber: 1,
result: null, result: null,
@ -155,8 +155,18 @@ function createTestContext(state?: CombatState): IGameContext<CombatState> {
} }
function getTriggers(): Triggers { function getTriggers(): Triggers {
const triggers = createTriggers(); const run: IRunContext = {
addTriggers(triggers); getConsumedUses() {
return 0;
},
getItemData() {
return null;
},
*getNeighborItems() {},
async setConsumedUsesAsync() {},
};
const triggers = createTriggers(run);
addTriggers(triggers, run);
return triggers; return triggers;
} }

View File

@ -5,6 +5,7 @@
"lib": ["ES2020"], "lib": ["ES2020"],
"moduleResolution": "bundler", "moduleResolution": "bundler",
"strict": true, "strict": true,
"types": ["node"],
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
@ -18,6 +19,6 @@
"@/*": ["src/*"], "@/*": ["src/*"],
}, },
}, },
"include": ["src/**/*", "tests/**/*"], "include": ["src/**/*", "tests/**/*", "*.config.ts"],
"exclude": ["node_modules", "dist"], "exclude": ["node_modules", "dist"],
} }

View File

@ -1,20 +1,20 @@
import { defineConfig } from 'tsup'; import { defineConfig } from "tsup";
import { fileURLToPath } from 'url'; import { fileURLToPath } from "url";
import { csvLoader } from 'inline-schema/csv-loader/rollup'; import { csvLoader } from "inline-schema/csv-loader/rollup";
const srcDir = fileURLToPath(new URL('./src', import.meta.url)); const srcDir = fileURLToPath(new URL("./src", import.meta.url));
export default defineConfig({ export default defineConfig({
entry: ['src/index.ts'], entry: ["src/index.ts"],
format: ['esm'], format: ["esm"],
dts: true, dts: true,
clean: true, clean: true,
sourcemap: true, sourcemap: true,
external: ['@preact/signals-core', 'mutative', 'inline-schema'], external: ["@preact/signals-core", "mutative", "inline-schema"],
plugins: [csvLoader()], plugins: [csvLoader()],
esbuildOptions(options) { esbuildOptions(options) {
options.alias = { options.alias = {
'@': srcDir, "@": srcDir,
}; };
}, },
}); });