diff --git a/src/art/pickups/ladder.png b/src/art/pickups/ladder.png index 0400710..bd9e4ac 100644 Binary files a/src/art/pickups/ladder.png and b/src/art/pickups/ladder.png differ diff --git a/src/engine/datatypes.ts b/src/engine/datatypes.ts index b5aff87..3e9edbf 100644 --- a/src/engine/datatypes.ts +++ b/src/engine/datatypes.ts @@ -55,6 +55,10 @@ export class Point { this.y = y; } + toString(): string { + return `${this.x},${this.y}` + } + offset(other: Point | Size): Point { if (other instanceof Point) { return new Point(this.x + other.x, this.y + other.y); @@ -81,6 +85,10 @@ export class Point { subtract(top: Point): Size { return new Size(this.x - top.x, this.y - top.y); } + + manhattan(other: Point) { + return Math.abs(this.x - other.x) + Math.abs(this.y - other.y); + } } export class Size { @@ -121,6 +129,20 @@ export class Rect { contains(other: Point) { return (other.x >= this.top.x && other.y >= this.top.y && other.x < this.top.x + this.size.w && other.y < this.top.y + this.size.h); } + + overlaps(other: Rect) { + let ax0 = this.top.x; + let ay0 = this.top.y; + let ax1 = ax0 + this.size.w; + let ay1 = ay0 + this.size.h; + let bx0 = other.top.x; + let by0 = other.top.y; + let bx1 = bx0 + other.size.w; + let by1 = by0 + other.size.h; + + let noOverlap = ax0 > bx1 || bx0 > ax1 || ay0 > by1 || by0 > ay1; + return !noOverlap; + } } export class Grid { @@ -201,7 +223,7 @@ export class Grid { (position.x < 0 || position.x >= this.size.w || Math.floor(position.x) != position.x) || (position.y < 0 || position.y >= this.size.h || Math.floor(position.y) != position.y) ) { - throw `invalid position for ${this.size}: ${position}` + throw new Error(`invalid position for ${this.size}: ${position}`) } } diff --git a/src/huntmode.ts b/src/huntmode.ts index b2f140e..6639b18 100644 --- a/src/huntmode.ts +++ b/src/huntmode.ts @@ -1,8 +1,8 @@ -import {Point, Rect, Size} from "./engine/datatypes.ts"; +import {Point} from "./engine/datatypes.ts"; import {ALL_STATS, Stat} from "./datatypes.ts"; import {DrawPile} from "./drawpile.ts"; import {D} from "./engine/public.ts"; -import {sprRaccoonWalking, sprResourcePickup, sprStatPickup} from "./sprites.ts"; +import {sprLadder, sprRaccoonWalking, sprResourcePickup, sprStatPickup} from "./sprites.ts"; import { BG_INSET, BG_WALL_OR_UNREVEALED, @@ -14,6 +14,7 @@ import {getPlayerProgress} from "./playerprogress.ts"; import {Architecture, LoadedNewMap} from "./newmap.ts"; import {FLOOR_CELL_SIZE, GridArt} from "./gridart.ts"; import {shadowcast} from "./shadowcast.ts"; +import {generateMap} from "./mapgen.ts"; export class HuntMode { @@ -41,13 +42,6 @@ export class HuntMode { #collectResources() { let cell = this.map.get(this.player); - /* - if (present.content.type == "stairs") { - getPlayerProgress().addBlood(1000); - initHuntMode(new HuntMode(this.depth + 1)); - } - */ - let pickup = cell.pickup; if (pickup != null) { switch (pickup) { @@ -59,9 +53,13 @@ export class HuntMode { getPlayerProgress().purloinItem(); break; case "EXP": - getPlayerProgress().addExperience(25); + getPlayerProgress().addExperience(250); getPlayerProgress().purloinItem(); break; + case "Ladder": + getPlayerProgress().addBlood(1000); + initHuntMode(new HuntMode(this.depth + 1, generateMap())); + break; default: throw `not sure how to handle ${pickup}` } @@ -84,6 +82,7 @@ export class HuntMode { if (dist != 1) { return null; } let pickup = present.pickup; + if (pickup == "Ladder") { return 0; } if (pickup == null) { return 10; } return 100; // any other pickup (EXP, stats, etc) } @@ -127,7 +126,8 @@ export class HuntMode { shadowcast( [this.player.x, this.player.y], ([x, y]: [number, number]): boolean => { - return this.map.get(new Point(x, y)).architecture == Architecture.Wall; + let cell = this.map.get(new Point(x, y)); + return cell.architecture == Architecture.Wall || ALL_STATS.indexOf(cell.pickup as Stat) != -1; }, ([x, y]: [number, number]) => { let dx = x - this.player.x; @@ -183,6 +183,13 @@ export class HuntMode { (hover: boolean) => { gridArt.drawFloor(hover ? FG_TEXT : BG_INSET) + if (cellData.pickup == "Ladder") { + D.drawSprite(sprLadder, gridArt.project(0.0), 0, { + xScale: 2.0, + yScale: 2.0, + }) + } + /* // TODO: Stairs if (cellData.content.type == "stairs") { diff --git a/src/mapgen.ts b/src/mapgen.ts new file mode 100644 index 0000000..f186066 --- /dev/null +++ b/src/mapgen.ts @@ -0,0 +1,579 @@ +import {Architecture, LoadedNewMap} from "./newmap.ts"; +import {Grid, Point, Rect, Size} from "./engine/datatypes.ts"; +import {choose, shuffle} from "./utils.ts"; +import {standardVaultTemplates, VaultTemplate} from "./vaulttemplate.ts"; +import {ALL_STATS} from "./datatypes.ts"; + +const WIDTH = 19; +const HEIGHT = 19; + +const MIN_VAULTS = 1; +const MAX_VAULTS = 1; +const NUM_VAULT_TRIES = 90; +const NUM_ROOM_TRIES = 90; +const NUM_STAIRCASE_TRIES = 90; +const NUM_STAIRCASES_DESIRED = 3 +const NUM_ROOMS_DESIRED = 4; + +const EXTRA_CONNECTOR_CHANCE = 0.15; +const WINDING_PERCENT = 0; + +// This is an implementation of Nystrom's algorithm: +// https://journal.stuffwithstuff.com/2014/12/21/rooms-and-mazes/ +class Knife { + #map: LoadedNewMap + #region: number + #regions: Grid + #sealedWalls: Grid + + constructor(map: LoadedNewMap, regions: Grid, sealedWalls: Grid) { + this.#map = map; + this.#region = -1; + this.#regions = regions + this.#sealedWalls = sealedWalls; + } + + get map(): LoadedNewMap { + return this.#map; + } + + get region(): number { + return this.#region; + } + + get regions(): Grid { + return this.#regions; + } + + get sealedWalls(): Grid { + return this.#sealedWalls; + } + + startRegion() { this.#region += 1; } + + carve(point: Point) { + this.#regions.set(point, this.#region) + this.map.get(point).architecture = Architecture.Floor; + } + + carveRoom(room: Rect, protect?: boolean) { + for (let y = room.top.y; y < room.top.y + room.size.h; y++) { + for (let x = room.top.x; x < room.top.x + room.size.w; x++) { + this.carve(new Point(x, y)); + } + } + + if (protect ?? false) { + for (let y = room.top.y - 1; y < room.top.y + room.size.h + 1; y++) { + for (let x = room.top.x - 1; x < room.top.x + room.size.w + 1; x++) { + this.#sealedWalls.set(new Point(x, y), true) + } + } + } + } +} + +export function generateMap(): LoadedNewMap { + for (let i= 0; i < 1000; i++) { + try { + return tryGenerateMap(standardVaultTemplates) + } catch (e) { + if (e instanceof TryAgainException) { + continue; + } + throw e; + } + } + throw new Error("couldn't generate map in 1000 attempts") +} +export function tryGenerateMap(vaultTemplates: VaultTemplate[]): LoadedNewMap { + let width = WIDTH; + let height = HEIGHT; + if (width % 2 == 0 || height % 2 == 0) { throw "must be odd-sized"; } + + let grid = new LoadedNewMap("generated", new Size(width, height)); + + let regions: Grid = new Grid(grid.size, () => null); + let sealedWalls: Grid = new Grid(grid.size, () => false); + let knife = new Knife(grid, regions, sealedWalls); + + let rooms = addRooms(knife, vaultTemplates); + showDebug(grid); + + for (let y = 1; y < grid.size.h; y += 2) { + for (let x = 1; x < grid.size.w; x += 2) { + let pos = new Point(x, y); + if (grid.get(pos).architecture == Architecture.Wall) { + growMaze(knife, pos); + } + } + } + + showDebug(grid); + + connectRegions(knife); + removeDeadEnds(knife); + + for (let r of rooms.values()) { + decorateRoom(grid, r); + } + + showDebug(grid); + + return grid; +} + + +class RoomChain { + #size: Size; + rooms: Rect[]; + + constructor(size: Size) { + this.#size = size; + this.rooms = [] + } + + reserve(width: number, height: number): Rect | null { + let x = randrange(0, Math.floor((this.#size.w - width) / 2)) * 2 + 1; + let y = randrange(0, Math.floor((this.#size.h - height) / 2)) * 2 + 1; + + let room = new Rect(new Point(x, y), new Size(width, height)); + + for (let other of this.rooms.values()) { + if (room.overlaps(other)) { + return null; + } + } + + this.rooms.push(room); + return room + } +} + +function addRooms(knife: Knife, vaultTemplates: VaultTemplate[]): Rect[] { + vaultTemplates = [...vaultTemplates]; // so we can mutate it + shuffle(vaultTemplates); + let chain = new RoomChain(knife.map.size); + let nVaults = 0; + let nVaultsDesired = randrange(MIN_VAULTS, MAX_VAULTS + 1); + + for (let i = 0; vaultTemplates.length > 0 && nVaults < nVaultsDesired && i < NUM_VAULT_TRIES; i += 1) { + let width = 7; + let height = 7; + + let room = chain.reserve(width, height); + + if (!room) { continue; } + + nVaults += 1; + carveVault(knife, room, vaultTemplates.pop()!); + } + + // staircases + let nStaircases = 0; + let nStaircasesDesired = NUM_STAIRCASES_DESIRED; + for (let i = 0; nStaircases < nStaircasesDesired && i < NUM_STAIRCASE_TRIES; i += 1) { + let width = 3; + let height = 3; + + let room = chain.reserve(width, height); + if (!room) { continue; } + nStaircases += 1; + carveStaircase(knife, room, nStaircases - 1); + } + + if (nStaircases == 0) { + throw new TryAgainException("couldn't make any staircases"); + } + + // rooms + let nRooms = 0; + let nRoomsDesired = NUM_ROOMS_DESIRED; + for (let i = 0; nRooms < nRoomsDesired && i < NUM_ROOM_TRIES; i += 1) { + let [width, height] = choose([[3, 5], [5, 3]]) + + let room = chain.reserve(width, height); + + if (!room) { continue; } + nRooms += 1; + + carveRoom(knife, room); + } + return chain.rooms; +} + +function carveVault(knife: Knife, room: Rect, vaultTemplate: VaultTemplate) { + if (room.size.w != 7 || room.size.h != 7) { + throw new Error("room must be 7x7") + } + + let quad0 = new Rect(room.top, new Size(3, 3)) + let quad1 = new Rect(room.top.offset(new Point(4, 0)), new Size(3, 3)) + let quad2 = new Rect(room.top.offset(new Point(4, 4)), new Size(3, 3)) + let quad3 = new Rect(room.top.offset(new Point(0, 4)), new Size(3, 3)) + + let [a, b, c, d] = choose([ + [quad0, quad1, quad2, quad3], + [quad1, quad2, quad3, quad0], + [quad2, quad3, quad0, quad1], + [quad3, quad0, quad1, quad2], + [quad3, quad2, quad1, quad0], + [quad2, quad1, quad0, quad3], + [quad1, quad0, quad3, quad2], + [quad0, quad3, quad2, quad1], + ]); + + let ab = mergeRects(a, b); + + knife.startRegion(); + knife.carveRoom(ab); + knife.carveRoom(c, true); + knife.carveRoom(d, true); + + // now place standard pickups + for (let dy = 0; dy < ab.size.h; dy++) { + for (let dx = 0; dx < ab.size.w; dx++) { + // this is a ratio of 14 to 6 + let xy = ab.top.offset(new Point(dx, dy)); + let stat = vaultTemplate.stats.secondary; + if (dx == 0 || dy == 0 || dx == ab.size.w - 1 || dy == ab.size.h - 1) { + stat = vaultTemplate.stats.primary; + } + if (!(a.contains(xy) || b.contains(xy))) { + stat = vaultTemplate.stats.secondary; + } + knife.map.get(xy).pickup = stat; + } + } + + for (let dy = 0; dy < c.size.h; dy++) { + for (let dx = 0; dx < c.size.w; dx++) { + let xy = c.top.offset(new Point(dx, dy)); + knife.map.get(xy).pickup = vaultTemplate.stats.primary; + } + } + + for (let dy = 0; dy < d.size.h; dy++) { + for (let dx = 0; dx < d.size.w; dx++) { + let xy = d.top.offset(new Point(dx, dy)); + knife.map.get(xy).pickup = vaultTemplate.stats.primary; + } + } + + // now build connectors + let connectors = [ + new Point(3, 1), + new Point(5, 3), + new Point(3, 5), + new Point(1, 3) + ]; + for (let offset of connectors.values()) { + let connector = room.top.offset(offset); + + if (mergeRects(b, c).contains(connector)) { + // TODO: Put check 1 here + knife.carve(connector) + } + if (mergeRects(c, d).contains(connector)) { + // TODO: Put check 2 here + knife.carve(connector) + } + } + + // now place goodies + let goodies = [ + new Point(1, 1), + new Point(5, 1), + new Point(1, 5), + new Point(5, 5), + ] + for (let offset of goodies.values()) { + let goodie = room.top.offset(offset); + + if (a.contains(goodie)) { + // TODO: Place the zone's NPC here + } + + if (b.contains(goodie)) { + knife.map.setPickup(goodie, "EXP"); + } + + if (c.contains(goodie)) { + knife.map.setPickup(goodie, "EXP"); + // TODO: Fill this room with the common item for this room + } + + if (d.contains(goodie)) { + // TOOD: Put a fancy item here + } + } +} + +function carveStaircase(knife: Knife, room: Rect, ix: number) { + carveRoom(knife, room); + + let x = Math.floor(room.top.x + room.size.w / 2); + let y = Math.floor(room.top.y + room.size.h / 2); + let center = new Point(x, y); + + if (ix == 0) { + // first staircase is the player entrance + knife.map.entrance = center; + knife.map.get(center).pickup = null; + } else { + knife.map.get(center).pickup = "Ladder"; + } +} + +function carveRoom(knife: Knife, room: Rect) { + knife.startRegion(); + for (let y = room.top.y; y < room.top.y + room.size.h; y++) { + for (let x = room.top.x; x < room.top.x + room.size.w; x++) { + knife.carve(new Point(x, y)); + } + } + + for (let dy = 0; dy < Math.ceil(room.size.h / 2); dy++) { + for (let dx = 0; dx < Math.ceil(room.size.w / 2); dx++) { + let xy0 = room.top.offset(new Point(dx, dy)); + let xy1 = room.top.offset(new Point(room.size.w - dx - 1, dy)); + let xy2 = room.top.offset(new Point(dx, room.size.h - dy - 1)); + let xy3 = room.top.offset(new Point(room.size.w - dx - 1, room.size.h - dy - 1)); + let stat = choose(ALL_STATS); + knife.map.get(xy0).pickup = stat; + knife.map.get(xy1).pickup = stat; + knife.map.get(xy2).pickup = stat; + knife.map.get(xy3).pickup = stat; + } + } +} + +let mergeRects = (a: Rect, b: Rect) => { + let abx0 = Math.min(a.top.x, b.top.x); + let aby0 = Math.min(a.top.y, b.top.y); + let abx1 = Math.max(a.top.x + a.size.w, b.top.x + b.size.w); + let aby1 = Math.max(a.top.y + a.size.h, b.top.y + b.size.h); + + return new Rect( + new Point(abx0, aby0), + new Size(abx1 - abx0, aby1 - aby0) + ); +} + +const _CARDINAL_DIRECTIONS = [ + new Point(-1, 0), + new Point(0, -1), + new Point(1, 0), + new Point(0, 1), +] + +function connectRegions(knife: Knife) { + // this procedure is really complicated + // so please read here: https://github.com/munificent/hauberk/blob/db360d9efa714efb6d937c31953ef849c7394a39/lib/src/content/dungeon.dart#L173 + let connectorRegions: Grid = new Grid(knife.map.size, () => []); + let connectors: Point[] = []; + + for (let y = 1; y < knife.map.size.h - 1; y++) { + for (let x = 1; x < knife.map.size.w - 1; x++) { + let pos = new Point(x, y); + if (knife.sealedWalls.get(pos)) { + continue; + } + if (knife.map.get(pos).architecture != Architecture.Wall) { + continue; + } + + let regions = []; + for (let offset of _CARDINAL_DIRECTIONS.values()) { + let region = knife.regions.get(pos.offset(offset)); + if (region != null) { + regions.push(region); + } + } + regions = dedup(regions); + if (regions.length < 2) { continue; } + + connectorRegions.set(pos, regions); + connectors.push(pos); + } + } + + // map from original index to "region it has been merged to" index + let merged: Record = {} + let openRegions = []; + for (let i = 0; i <= knife.region; i++) { + merged[i] = i; + openRegions.push(i); + } + + let iter = 0; + + while (openRegions.length > 1) { + if (iter > 100) { + throw new TryAgainException("algorithm was not quiescent for some reason"); + } + iter++; + showDebug(knife.map); + if (connectors.length == 0) { + throw new TryAgainException("couldn't figure out how to connect sections") + } + let connector = choose(connectors); + + // create the connections + knife.map.get(connector).architecture = Architecture.Floor; + let basicRegions: number[] = connectorRegions.get(connector); + let sources: number[] = dedup(basicRegions.map((i) => merged[i])); + let dest: number | undefined = sources.pop(); + if (dest == undefined) { + throw "each connector should touch more than one region" + } + + if (Math.random() > EXTRA_CONNECTOR_CHANCE) { + // at random, don't regard them as merged + for (let i = 0; i < knife.region; i++) { + if (sources.indexOf(merged[i]) != -1) { + merged[i] = dest; + } + } + + for (let src of sources.values()) { + let ix = openRegions.indexOf(src); + if (ix != -1) { openRegions.splice(ix, 1); } + } + } + + let connectors2 = []; + for (let other of connectors.values()) { + if (other.manhattan(connector) == 1) { continue; } + + let connected = dedup( + connectorRegions.get(other).map((m) => merged[m]) + ); + if (connected.length <= 1) { continue; } + + connectors2.push(other); + } + connectors = connectors2; + } +} + +function growMaze(knife: Knife, start: Point) { + let cells: Point[] = []; + let lastDir: Point | null = null; + + knife.startRegion(); + knife.carve(start); + + cells.push(start); + while (cells.length > 0) { + let cell = cells[cells.length - 1]; + + let unmadeCells: Point[] = []; + let lastDirOk = false; + for (let dir of _CARDINAL_DIRECTIONS.values()) { + if (canCarve(knife, cell, dir)) { + unmadeCells.push(dir); + if (lastDir != null && dir.equals(lastDir)) { + lastDirOk = true; + } + } + } + + if (unmadeCells.length == 0) { + cells.pop(); + lastDir = null; + continue + } + + let dir: Point; + if (lastDirOk && randrange(0, 100) > WINDING_PERCENT) { + dir = lastDir!; + } else { + dir = choose(unmadeCells); // TODO: Constrain windiness as Nystrom did + } + + let c1 = cell.offset(dir); + let c2 = cell.offset(dir).offset(dir); + knife.carve(c1); + knife.carve(c2); + cells.push(c2) + lastDir = dir; + } +} + +function canCarve(knife: Knife, pos: Point, direction: Point) { + let c2 = pos.offset(direction).offset(direction); + let c3 = c2.offset(direction); + let rect = new Rect(new Point(0, 0), knife.map.size); + if (!rect.contains(c3)) { + return false; + } + + return knife.map.get(c2).architecture == Architecture.Wall; +} + + +function removeDeadEnds(knife: Knife) { + let done = false; + + while (!done) { + done = true; + + for (let y = 1; y < knife.map.size.h - 1; y++) { + for (let x = 1; x < knife.map.size.w - 1; x++) { + let xy = new Point(x, y); + if (knife.map.get(xy).architecture == Architecture.Wall) { continue; } + + let exits = 0; + for (let dir of _CARDINAL_DIRECTIONS.values()) { + if (knife.map.get(xy.offset(dir)).architecture != Architecture.Wall) { + exits++; + } + } + + if (exits != 1) { continue; } + + done = false; + knife.map.get(xy).architecture = Architecture.Wall; + } + } + } +} + +function decorateRoom(_map: LoadedNewMap, _rect: Rect) { + +} + +function randrange(lo: number, hi: number) { + if (lo >= hi) { + throw `randrange: hi must be >= lo, ${hi}, ${lo}` + } + + return lo + Math.floor(Math.random() * (hi - lo)) +} + +function dedup(items: number[]): number[] { + let deduped = []; + for (let i of items.values()) { + if (deduped.indexOf(i) != -1) { continue; } + deduped.push(i); + } + return deduped; +} + +function showDebug(grid: LoadedNewMap) { + if (true) { + let out = ""; + for (let y = 0; y < grid.size.h; y++) { + for (let x = 0; x < grid.size.w; x++) { + out += grid.get(new Point(x, y)).architecture == Architecture.Wall ? "#" : "."; + } + out += "\n"; + } + console.log(out); + } +} + +class TryAgainException extends Error { + +} \ No newline at end of file diff --git a/src/newmap.ts b/src/newmap.ts index e5aeb92..cd88829 100644 --- a/src/newmap.ts +++ b/src/newmap.ts @@ -6,7 +6,7 @@ import {VNScene} from "./vnscene.ts"; export type Province = "a" | "b" | "c"; export type Check = "1" | "2"; export type Progress = Stat | Resource -export type Pickup = Progress; // TODO: Items +export type Pickup = Progress | "Ladder"; // TODO: Items export type NewMapInput = { id: string, data: { diff --git a/src/sprites.ts b/src/sprites.ts index a784f96..727b758 100644 --- a/src/sprites.ts +++ b/src/sprites.ts @@ -46,6 +46,6 @@ export let sprDrips = new Sprite( ); export let sprLadder = new Sprite( - imgLadder, new Size(32, 24), new Point(0, 0), + imgLadder, new Size(16, 16), new Point(8, 8), new Size(1, 1), 1 ); diff --git a/src/statemanager.ts b/src/statemanager.ts index 0fa3026..89e7651 100644 --- a/src/statemanager.ts +++ b/src/statemanager.ts @@ -5,7 +5,7 @@ import {getVNModal} from "./vnmodal.ts"; import {getScorer} from "./scorer.ts"; import {getEndgameModal} from "./endgamemodal.ts"; import {SuccessorOption, Wish} from "./datatypes.ts"; -import mapHub from "./newmaps/hub/map.ts"; +import {generateMap} from "./mapgen.ts"; const N_TURNS: number = 9; @@ -22,7 +22,7 @@ export class StateManager { startGame(asSuccessor: SuccessorOption, withWish: Wish | null) { this.#turn = 1; - initHuntMode(new HuntMode(1, mapHub())); + initHuntMode(new HuntMode(1, generateMap())); initPlayerProgress(asSuccessor, withWish); } @@ -33,7 +33,7 @@ export class StateManager { this.#turn += 1; getPlayerProgress().applyEndOfTurn(); getPlayerProgress().refill(); - initHuntMode(new HuntMode(getHuntMode().depth, mapHub())); + initHuntMode(new HuntMode(getHuntMode().depth, generateMap())); } else { // TODO: Play a specific scene let ending = getScorer().pickEnding(); diff --git a/src/utils.ts b/src/utils.ts index 80ec0cc..947bb24 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ export function choose(array: Array): T { if (array.length == 0) { - throw `array cannot have length 0 for choose` + throw new Error(`array cannot have length 0 for choose`); } return array[Math.floor(Math.random() * array.length)] } diff --git a/src/vaulttemplate.ts b/src/vaulttemplate.ts new file mode 100644 index 0000000..4843f96 --- /dev/null +++ b/src/vaulttemplate.ts @@ -0,0 +1,32 @@ +import {Stat} from "./datatypes.ts"; + +export type VaultTemplate = { + stats: {primary: Stat, secondary: Stat}, +} + +export const standardVaultTemplates: VaultTemplate[] = [ + { + // blood bank + stats: {primary: "AGI", secondary: "INT"}, + }, + { + // club, + stats: {primary: "CHA", secondary: "PSI"}, + }, + { + // coffee shop + stats: {primary: "PSI", secondary: "CHA"}, + }, + { + // library + stats: {primary: "INT", secondary: "CHA"}, + }, + { + // optometrist + stats: {primary: "PSI", secondary: "PSI"}, + }, + { + // zoo + stats: {primary: "AGI", secondary: "PSI"} + } +] \ No newline at end of file