Exact source used by this build. SHA-256: 3d7617aebaef527458f93b53812026869110b2238f2cef4d5c864082492dcc29.
1 import { z } from "zod";
2 import type { Player, SimRules } from "../../types";
3 import { defineIntent, defineSystem } from "../registry";
4
5 export const SKILL_IDS = ["farming", "mining", "foraging", "fishing", "combat"] as const;
6 export type SkillId = typeof SKILL_IDS[number];
7 export type StubProfessionHook = `${Exclude<SkillId, "farming">}_0${1 | 2 | 3 | 4 | 5 | 6}`;
8 export type ProfessionEffect =
9 | { kind: "sale"; category?: string; tag?: string; excludedTags?: string[]; multiplier: number }
10 | { kind: "growth"; reduction: number }
11 | { kind: "animal"; group: "coop" | "barn"; friendshipMultiplier: number; incubationMultiplier?: number; woolDaysReduction?: number; qualityBonus: number }
12 | { kind: "hook"; hook: StubProfessionHook };
13 export interface ProfessionDefinition { id: string; skill: SkillId; level: 5 | 10; parentId: string | null; effects: ProfessionEffect[]; name: string; description: string }
14 export interface SkillsContent { xpTable: number[]; skills: { id: SkillId; name: string; description: string }[]; professions: ProfessionDefinition[] }
15 export interface SkillProgress { xp: number; level: number }
16 export interface PendingLevel { skill: SkillId; level: number }
17 export interface SkillsState { skills: Record<SkillId, SkillProgress>; professions: string[]; pendingLevels: PendingLevel[] }
18
19 function defaults(): SkillsState { return { skills: Object.fromEntries(SKILL_IDS.map((id) => [id, { xp: 0, level: 0 }])) as Record<SkillId, SkillProgress>, professions: [], pendingLevels: [] }; }
20 export function getSkills(player: Player): SkillsState {
21 const saved = player.systems.skills as Partial<SkillsState> | undefined;
22 return { ...defaults(), ...saved, skills: { ...defaults().skills, ...saved?.skills } };
23 }
24 export function getSkillsContent(rules: SimRules): SkillsContent | undefined { return rules.content?.skills as SkillsContent | undefined; }
25 export function skillLevel(player: Player, skill: SkillId): number { return getSkills(player).skills[skill].level; }
26 export function levelForXp(xp: number, table: readonly number[]): number { return table.reduce((level, threshold) => level + (xp >= threshold ? 1 : 0), 0); }
27 export function farmingHarvestXp(basePrice: number): number { return Math.round(16 * Math.log(0.018 * Math.max(0, basePrice) + 1)); }
28 export function grantXp(player: Player, skill: SkillId, amount: number, _rules: SimRules): Player {
29 if (!Number.isFinite(amount) || amount <= 0) return player;
30 const state = getSkills(player), progress = state.skills[skill];
31 return { ...player, systems: { ...player.systems, skills: { ...state, skills: { ...state.skills, [skill]: { ...progress, xp: progress.xp + Math.floor(amount) } } } } };
32 }
33 export function professionEffects(player: Player, rules: SimRules): ProfessionEffect[] {
34 const selected = new Set(getSkills(player).professions);
35 return getSkillsContent(rules)?.professions.filter((profession) => selected.has(profession.id)).flatMap((profession) => profession.effects) ?? [];
36 }
37 export function farmingGrowthMultiplier(player: Player, rules: SimRules): number {
38 return Math.max(0, 1 - professionEffects(player, rules).reduce((sum, effect) => sum + (effect.kind === "growth" ? effect.reduction : 0), 0));
39 }
40 export function professionSaleMultiplier(player: Player, item: { category: string; tags: readonly string[] }, rules: SimRules): number {
41 return professionEffects(player, rules).reduce((modifier, effect) => {
42 if (effect.kind !== "sale" || (effect.category && effect.category !== item.category) || (effect.tag && !item.tags.includes(effect.tag)) || effect.excludedTags?.some((tag) => item.tags.includes(tag))) return modifier;
43 return modifier * effect.multiplier;
44 }, 1);
45 }
46 /** Farming's animal branches are usable now by later animal producers without ID switches. */
47 export function farmingAnimalModifiers(player: Player, group: "coop" | "barn", rules: SimRules): { friendshipMultiplier: number; incubationMultiplier: number; woolDaysReduction: number; qualityBonus: number } {
48 return professionEffects(player, rules).reduce((result, effect) => effect.kind !== "animal" || effect.group !== group ? result : ({
49 friendshipMultiplier: result.friendshipMultiplier * effect.friendshipMultiplier,
50 incubationMultiplier: result.incubationMultiplier * (effect.incubationMultiplier ?? 1),
51 woolDaysReduction: result.woolDaysReduction + (effect.woolDaysReduction ?? 0), qualityBonus: result.qualityBonus + effect.qualityBonus,
52 }), { friendshipMultiplier: 1, incubationMultiplier: 1, woolDaysReduction: 0, qualityBonus: 0 });
53 }
54 /** Non-farming effects remain typed hooks for their future owning systems. */
55 export function hasProfessionHook(player: Player, hook: StubProfessionHook, rules: SimRules): boolean { return professionEffects(player, rules).some((effect) => effect.kind === "hook" && effect.hook === hook); }
56
57 export const skillsSystem = defineSystem({
58 id: "skills", worldDefaults: () => ({}), playerDefaults: () => ({ ...defaults() }), events: ["skillLevelUp", "professionChosen", "levelAcknowledged"],
59 intents: [
60 defineIntent("chooseProfession", z.object({ type: z.literal("chooseProfession"), professionId: z.string().min(1) }), ({ state, player, playerId, rules }, intent) => {
61 const data = getSkillsContent(rules), profession = data?.professions.find((entry) => entry.id === intent.professionId), skills = getSkills(player);
62 if (!profession || skillLevel(player, profession.skill) < profession.level || (profession.parentId && !skills.professions.includes(profession.parentId))) return { state, events: [], error: "Profession is not available" };
63 if (data!.professions.some((entry) => entry.skill === profession.skill && entry.level === profession.level && skills.professions.includes(entry.id))) return { state, events: [], error: "A profession is already selected for this level" };
64 const next = { ...player, systems: { ...player.systems, skills: { ...skills, professions: [...skills.professions, profession.id] } } };
65 return { state: { ...state, revision: state.revision + 1, players: { ...state.players, [playerId]: next } }, events: [{ type: "professionChosen", playerId, data: { professionId: profession.id } }] };
66 }, { allowPaused: true }),
67 defineIntent("acknowledgeLevel", z.object({ type: z.literal("acknowledgeLevel"), skill: z.enum(SKILL_IDS), level: z.number().int().min(1).max(10) }), ({ state, player, playerId, rules }, intent) => {
68 const skills = getSkills(player);
69 if (!skills.pendingLevels.some((entry) => entry.skill === intent.skill && entry.level === intent.level)) return { state, events: [], error: "No pending level" };
70 if ([5, 10].includes(intent.level) && !getSkillsContent(rules)?.professions.some((entry) => entry.skill === intent.skill && entry.level === intent.level && skills.professions.includes(entry.id))) return { state, events: [], error: "Choose a profession first" };
71 const next = { ...player, systems: { ...player.systems, skills: { ...skills, pendingLevels: skills.pendingLevels.filter((entry) => entry.skill !== intent.skill || entry.level !== intent.level) } } };
72 return { state: { ...state, revision: state.revision + 1, players: { ...state.players, [playerId]: next } }, events: [{ type: "levelAcknowledged", playerId }] };
73 }, { allowPaused: true }),
74 ],
75 onOvernight: ({ state, rules }) => {
76 const table = getSkillsContent(rules)?.xpTable;
77 if (!table) return { state, events: [] };
78 const events: { type: string; playerId: string; data: { skill: SkillId; level: number } }[] = [];
79 const players = Object.fromEntries(Object.entries(state.players).map(([id, player]) => {
80 const saved = getSkills(player), pendingLevels = [...saved.pendingLevels], skills = { ...saved.skills };
81 let gained = false;
82 for (const skill of SKILL_IDS) {
83 const old = skills[skill], level = levelForXp(old.xp, table);
84 if (level <= old.level) continue;
85 gained = true; skills[skill] = { ...old, level };
86 for (let value = old.level + 1; value <= level; value++) { pendingLevels.push({ skill, level: value }); events.push({ type: "skillLevelUp", playerId: id, data: { skill, level: value } }); }
87 }
88 return [id, gained ? { ...player, energy: player.maxEnergy, systems: { ...player.systems, skills: { ...saved, skills, pendingLevels } } } : player];
89 }));
90 return events.length ? { state: { ...state, revision: state.revision + 1, players }, events } : { state, events: [] };
91 },
92 });
93